Fix server file root loading and refresh
This commit is contained in:
@@ -151,7 +151,6 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
|
|||||||
|
|
||||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||||
endpointRequest := validRunEndpointRequest()
|
endpointRequest := validRunEndpointRequest()
|
||||||
endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList)
|
|
||||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest)
|
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest)
|
||||||
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||||
ID: "server-metrics-api",
|
ID: "server-metrics-api",
|
||||||
@@ -267,6 +266,12 @@ func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) {
|
|||||||
if list.State != "declared" || list.DirectoryKey != "server-root" || list.Entries == nil || !strings.Contains(list.Reason, "服务器文件缓存") {
|
if list.State != "declared" || list.DirectoryKey != "server-root" || list.Entries == nil || !strings.Contains(list.Reason, "服务器文件缓存") {
|
||||||
t.Fatalf("expected generic root file list, got %+v", list)
|
t.Fatalf("expected generic root file list, got %+v", list)
|
||||||
}
|
}
|
||||||
|
refreshRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/refresh", dto.ServerFileListRequest{DirectoryKey: "server-root", IdempotencyKey: "api-file-list-refresh"}, adminSession)
|
||||||
|
assertStatus(t, refreshRecorder, http.StatusAccepted)
|
||||||
|
refresh := decodeBody[dto.ServerFileListResponse](t, refreshRecorder)
|
||||||
|
if refresh.State != "pending" || refresh.Job == nil || refresh.Job.Capability != domain.JobCapabilityFilesList {
|
||||||
|
t.Fatalf("expected file list refresh without endpoint declaration gate, got %+v", refresh)
|
||||||
|
}
|
||||||
readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession)
|
readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession)
|
||||||
assertStatus(t, readRecorder, http.StatusAccepted)
|
assertStatus(t, readRecorder, http.StatusAccepted)
|
||||||
read := decodeBody[dto.FileOperationDispatchResponse](t, readRecorder)
|
read := decodeBody[dto.FileOperationDispatchResponse](t, readRecorder)
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
|||||||
if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs {
|
if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs {
|
||||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
||||||
}
|
}
|
||||||
if session.RequireSignedRequests && len(claim.Capabilities) == 0 {
|
|
||||||
return emptyJobClaim(claim.RunEndpointID, stamp), nil
|
|
||||||
}
|
|
||||||
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.RunJobClaimResult{}, err
|
return domain.RunJobClaimResult{}, err
|
||||||
@@ -614,7 +611,7 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t
|
|||||||
if !eligible || !job.CancelRequestedAt.IsZero() {
|
if !eligible || !job.CancelRequestedAt.IsZero() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(capabilitySet) > 0 {
|
if len(capabilitySet) > 0 && !isServerFileCapability(job.Capability) {
|
||||||
if _, supported := capabilitySet[job.Capability]; !supported {
|
if _, supported := capabilitySet[job.Capability]; !supported {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2754,6 +2754,9 @@ func (svc *CoreService) validateRunnableEndpoint(endpoint domain.RunEndpoint, ca
|
|||||||
if !svc.runEndpointHeartbeatCurrent(endpoint) {
|
if !svc.runEndpointHeartbeatCurrent(endpoint) {
|
||||||
return validationError("run endpoint heartbeat is stale")
|
return validationError("run endpoint heartbeat is stale")
|
||||||
}
|
}
|
||||||
|
if isServerFileCapability(capability) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if len(validator.MissingCapabilities(endpoint.Capabilities, []string{capability})) > 0 {
|
if len(validator.MissingCapabilities(endpoint.Capabilities, []string{capability})) > 0 {
|
||||||
return validationError("run endpoint missing required capability: " + capability)
|
return validationError("run endpoint missing required capability: " + capability)
|
||||||
}
|
}
|
||||||
@@ -2782,7 +2785,7 @@ func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plu
|
|||||||
if plugin.ID != instance.PluginID {
|
if plugin.ID != instance.PluginID {
|
||||||
return validationError("job plugin must match server instance")
|
return validationError("job plugin must match server instance")
|
||||||
}
|
}
|
||||||
if job.Capability != domain.JobCapabilityDistributionBuild && !containsString(plugin.RequiredRunCapabilities, job.Capability) {
|
if job.Capability != domain.JobCapabilityDistributionBuild && !isServerFileCapability(job.Capability) && !containsString(plugin.RequiredRunCapabilities, job.Capability) {
|
||||||
return validationError("plugin missing required capability: " + job.Capability)
|
return validationError("plugin missing required capability: " + job.Capability)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -101,14 +101,14 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
state := "declared"
|
state := "declared"
|
||||||
reason := "展示服务器文件缓存;点击刷新目录读取 Run 实时目录。"
|
reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。"
|
||||||
if hasLatest && !isTerminalJobState(latest.State) {
|
if hasLatest && !isTerminalJobState(latest.State) {
|
||||||
state = "pending"
|
state = "pending"
|
||||||
reason = "Run 正在刷新目录。"
|
reason = "Run 正在刷新目录。"
|
||||||
}
|
}
|
||||||
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
|
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
|
||||||
if state == "declared" {
|
if state == "declared" {
|
||||||
reason = "展示服务器文件缓存;点击刷新目录读取 Run 实时目录。"
|
reason = "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。"
|
||||||
}
|
}
|
||||||
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil
|
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil
|
||||||
}
|
}
|
||||||
@@ -425,7 +425,34 @@ func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []d
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) {
|
func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) {
|
||||||
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesList, directoryKey)
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
|
||||||
|
if err != nil {
|
||||||
|
return domain.Job{}, false, err
|
||||||
|
}
|
||||||
|
var latest domain.Job
|
||||||
|
found := false
|
||||||
|
for _, job := range jobs {
|
||||||
|
if job.Capability != domain.JobCapabilityFilesList || job.TargetKey != directoryKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if job.ExecutionInput.Inputs["path"] != relativePath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) {
|
||||||
|
latest = job
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.CopyJob(latest), found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isServerFileCapability(capability string) bool {
|
||||||
|
switch capability {
|
||||||
|
case domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) {
|
func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) {
|
||||||
|
|||||||
@@ -1270,6 +1270,13 @@ func ValidateServerInstanceDependenciesForCapabilities(instance domain.ServerIns
|
|||||||
violations = append(violations, "run endpoint must be online or degraded")
|
violations = append(violations, "run endpoint must be online or degraded")
|
||||||
}
|
}
|
||||||
missing := MissingCapabilities(endpoint.Capabilities, requiredRunCapabilities)
|
missing := MissingCapabilities(endpoint.Capabilities, requiredRunCapabilities)
|
||||||
|
fileCapabilityMissing := missing[:0]
|
||||||
|
for _, capability := range missing {
|
||||||
|
if capability != "files.list" && capability != "files.read" && capability != "files.write" {
|
||||||
|
fileCapabilityMissing = append(fileCapabilityMissing, capability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing = fileCapabilityMissing
|
||||||
if len(missing) > 0 {
|
if len(missing) > 0 {
|
||||||
violations = append(violations, "run endpoint missing required capabilities: "+strings.Join(missing, ", "))
|
violations = append(violations, "run endpoint missing required capabilities: "+strings.Join(missing, ", "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,12 +87,15 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
|
|
||||||
it("keeps the server file manager list-first without plugin declaration gates", () => {
|
it("keeps the server file manager list-first without plugin declaration gates", () => {
|
||||||
expect(serverDetailPageSource).toContain("server-file-editor-overlay");
|
expect(serverDetailPageSource).toContain("server-file-editor-overlay");
|
||||||
|
expect(serverDetailPageSource).toContain("refreshRuntimeList({ silent: true })");
|
||||||
|
expect(serverDetailPageSource).toContain("window.setInterval(() => void loadList(), 2000)");
|
||||||
expect(serverDetailPageSource).not.toContain("server-file-layout");
|
expect(serverDetailPageSource).not.toContain("server-file-layout");
|
||||||
expect(serverDetailPageSource).not.toContain("未声明目录");
|
expect(serverDetailPageSource).not.toContain("未声明目录");
|
||||||
expect(serverDetailPageSource).not.toContain("插件尚未声明");
|
expect(serverDetailPageSource).not.toContain("插件尚未声明");
|
||||||
expect(serverDetailPageSource).not.toContain("插件声明为可编辑");
|
expect(serverDetailPageSource).not.toContain("插件声明为可编辑");
|
||||||
expect(serverDetailPageSource).not.toContain("entry.editable");
|
expect(serverDetailPageSource).not.toContain("entry.editable");
|
||||||
expect(serverDetailPageSource).not.toContain("entry.downloadable");
|
expect(serverDetailPageSource).not.toContain("entry.downloadable");
|
||||||
|
expect(serverDetailPageSource).not.toContain("当前目录暂无缓存结果;点击刷新目录读取实时文件。");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps run distribution and client-manager workflows out of server detail tabs", () => {
|
it("keeps run distribution and client-manager workflows out of server detail tabs", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react";
|
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react";
|
||||||
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type {
|
import type {
|
||||||
@@ -569,12 +569,14 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
const [panelResult, setPanelResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
const [panelResult, setPanelResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||||
const [uploadBusy, setUploadBusy] = useState(false);
|
const [uploadBusy, setUploadBusy] = useState(false);
|
||||||
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
|
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
|
||||||
|
const initialRuntimeLoadRef = useRef(false);
|
||||||
|
|
||||||
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
|
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
|
||||||
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && !uploadBusy;
|
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && !uploadBusy;
|
||||||
const entries = list.status === "ready" ? list.data.entries : [];
|
const entries = list.status === "ready" ? list.data.entries : [];
|
||||||
|
|
||||||
const loadWorkspace = useCallback(async () => {
|
const loadWorkspace = useCallback(async () => {
|
||||||
|
initialRuntimeLoadRef.current = false;
|
||||||
setWorkspace({ status: "loading" });
|
setWorkspace({ status: "loading" });
|
||||||
try {
|
try {
|
||||||
const response = await platformApiClient.getServerFileWorkspace(instance.id);
|
const response = await platformApiClient.getServerFileWorkspace(instance.id);
|
||||||
@@ -603,26 +605,37 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
void loadWorkspace();
|
void loadWorkspace();
|
||||||
}, [loadWorkspace]);
|
}, [loadWorkspace]);
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshRuntimeList = useCallback(async (options: { silent?: boolean } = {}) => {
|
||||||
if (workspace.status !== "ready" || !directoryKey) return;
|
|
||||||
void loadList();
|
|
||||||
}, [directoryKey, loadList, workspace.status]);
|
|
||||||
|
|
||||||
async function refreshRuntimeList() {
|
|
||||||
if (!directoryKey) return;
|
if (!directoryKey) return;
|
||||||
const operationId = operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
const operationId = options.silent ? "" : operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||||
setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" });
|
if (!options.silent) setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" });
|
||||||
try {
|
try {
|
||||||
const response = await platformApiClient.refreshServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: serverFileIdempotency("list", instance.id, directoryKey) });
|
const response = await platformApiClient.refreshServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: serverFileIdempotency("list", instance.id, directoryKey) });
|
||||||
setList({ status: "ready", data: response });
|
setList({ status: "ready", data: response });
|
||||||
operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job);
|
if (operationId) operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job);
|
||||||
setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" });
|
setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : "目录刷新失败";
|
const reason = error instanceof Error ? error.message : "目录刷新失败";
|
||||||
operations.fail(operationId, reason, operationId);
|
if (operationId) operations.fail(operationId, reason, operationId);
|
||||||
setPanelResult({ status: "failed", label: reason });
|
setPanelResult({ status: "failed", label: reason });
|
||||||
}
|
}
|
||||||
|
}, [directoryKey, instance.id, operations, relativePath, recursive, searchQuery, session.displayName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (workspace.status !== "ready" || !directoryKey) return;
|
||||||
|
if (!initialRuntimeLoadRef.current) {
|
||||||
|
initialRuntimeLoadRef.current = true;
|
||||||
|
void refreshRuntimeList({ silent: true });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
void loadList();
|
||||||
|
}, [directoryKey, loadList, refreshRuntimeList, workspace.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (workspace.status !== "ready" || !directoryKey || list.status !== "ready" || list.data.state !== "pending") return;
|
||||||
|
const timer = window.setInterval(() => void loadList(), 2000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [directoryKey, list, loadList, workspace.status]);
|
||||||
|
|
||||||
async function openEntry(entry: ServerFileEntryResponse) {
|
async function openEntry(entry: ServerFileEntryResponse) {
|
||||||
if (entry.kind === "directory") {
|
if (entry.kind === "directory") {
|
||||||
@@ -781,7 +794,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
|||||||
<table className="resource-table server-file-table">
|
<table className="resource-table server-file-table">
|
||||||
<thead><tr><th>文件名称</th><th>大小</th><th>修改时间</th><th>备注</th><th>操作</th></tr></thead>
|
<thead><tr><th>文件名称</th><th>大小</th><th>修改时间</th><th>备注</th><th>操作</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.length === 0 && <tr><td colSpan={5}><span className="provider-id">当前目录暂无缓存结果;点击刷新目录读取实时文件。</span></td></tr>}
|
{entries.length === 0 && <tr><td colSpan={5}><span className="provider-id">{list.data.state === "pending" ? "正在读取当前目录,Run 返回后会自动更新。" : "当前目录暂无文件。"}</span></td></tr>}
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
|
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
Reference in New Issue
Block a user