|
|
|
|
@ -14,19 +14,31 @@ import Plotly from "plotly.js-dist-min";
|
|
|
|
|
import CompareRunsDialog from "@/components/atoms/organisms/CompareRunDialog.vue";
|
|
|
|
|
import DeploymentDialog from "@/components/atoms/organisms/DeploymentDialog.vue";
|
|
|
|
|
import { MinioService } from "@/components/service/management/MinioService";
|
|
|
|
|
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
|
|
|
|
|
import IconDeployBtn from "@/components/atoms/button/IconDeployBtn.vue";
|
|
|
|
|
|
|
|
|
|
/* ========= Constants & Types ========= */
|
|
|
|
|
const AUTH_KEY = "external-auth";
|
|
|
|
|
const externalToken = computed(() => externalAuth.value?.token ?? "");
|
|
|
|
|
type ExternalAuth = { id: string; name: string; token: string };
|
|
|
|
|
type PackageOption = { label: string; value: string; raw: any };
|
|
|
|
|
|
|
|
|
|
type MlflowArtifactList = {
|
|
|
|
|
root_uri: string;
|
|
|
|
|
files: { path: string; is_dir: boolean; file_size?: number }[];
|
|
|
|
|
};
|
|
|
|
|
type ArtifactRow = {
|
|
|
|
|
path: string;
|
|
|
|
|
is_dir?: boolean;
|
|
|
|
|
file_size?: number;
|
|
|
|
|
depth?: number;
|
|
|
|
|
};
|
|
|
|
|
type MetricKV = { key: string; value: number };
|
|
|
|
|
type RunDetailType = {
|
|
|
|
|
info: any;
|
|
|
|
|
data: { metrics: MetricKV[]; params?: any[]; tags?: any[] };
|
|
|
|
|
};
|
|
|
|
|
type ArtifactRow = { path: string; is_dir?: boolean; file_size?: number };
|
|
|
|
|
type ArtifactGroup = { base: string; items: ArtifactRow[] };
|
|
|
|
|
|
|
|
|
|
type ExternalAuth = { id: string; name: string; token: string };
|
|
|
|
|
type PackageOption = { label: string; value: string; raw: any };
|
|
|
|
|
type ArtifactGroup = { dir: string; files: ArtifactRow[] };
|
|
|
|
|
const FILE_ICON = "mdi-file-document-outline";
|
|
|
|
|
/* ========= Props/Emits ========= */
|
|
|
|
|
const props = defineProps<{ experimentInfo: any }>();
|
|
|
|
|
const emit = defineEmits<{ (e: "close"): void }>();
|
|
|
|
|
@ -38,12 +50,12 @@ const loginForm = ref({ id: "", password: "" });
|
|
|
|
|
const loginError = ref("");
|
|
|
|
|
const isAuthenticated = ref(false);
|
|
|
|
|
const externalAuth = ref<ExternalAuth | null>(null);
|
|
|
|
|
const externalToken = computed(() => externalAuth.value?.token ?? "");
|
|
|
|
|
|
|
|
|
|
/* ========= Deployment State ========= */
|
|
|
|
|
const isEditVisible = ref(false);
|
|
|
|
|
const lastArtifactUri = ref<string>(""); // 배포 모달에 표시될 최종 URI
|
|
|
|
|
const pendingArtifactPath = ref<string | null>(null); // 로그인 전 임시 저장
|
|
|
|
|
|
|
|
|
|
const lastArtifactUri = ref<string>("");
|
|
|
|
|
const pendingArtifactPath = ref<string | null>(null);
|
|
|
|
|
const packageOptions = ref<PackageOption[]>([]);
|
|
|
|
|
const packagesLoading = ref(false);
|
|
|
|
|
const packagesError = ref("");
|
|
|
|
|
@ -72,6 +84,109 @@ const baselineRunId = ref<string | null>(null);
|
|
|
|
|
const elMetrics = ref<HTMLDivElement | null>(null);
|
|
|
|
|
const elCompare = ref<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
|
/* ========= Artifacts: Two-step(flat) ========= */
|
|
|
|
|
const artifactGroups = computed<ArtifactGroup[]>(() => {
|
|
|
|
|
const rootFiles = (lvl1.value || []).filter((x) => !x.is_dir);
|
|
|
|
|
const dirs = (lvl1.value || []).filter((x) => x.is_dir).map((x) => x.path);
|
|
|
|
|
|
|
|
|
|
const groups: ArtifactGroup[] = [];
|
|
|
|
|
for (const d of dirs) {
|
|
|
|
|
const children = (lvl2.value || [])
|
|
|
|
|
.filter((f) => f.path.startsWith(`${d}/`))
|
|
|
|
|
.map((f) => {
|
|
|
|
|
const rel = f.path.slice(d.length + 1);
|
|
|
|
|
const depth = Math.max(rel.split("/").length - 1, 0);
|
|
|
|
|
return { ...f, depth };
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
if (!!b.is_dir - !!a.is_dir) return !!b.is_dir - !!a.is_dir;
|
|
|
|
|
return a.path.localeCompare(b.path);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
groups.push({ dir: d, files: children });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (rootFiles.length > 0) {
|
|
|
|
|
groups.unshift({
|
|
|
|
|
dir: "(root)",
|
|
|
|
|
files: rootFiles.map((f) => ({ ...f, depth: 0 })),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return groups;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const lvl1 = ref<ArtifactRow[]>([]);
|
|
|
|
|
const lvl2 = ref<ArtifactRow[]>([]);
|
|
|
|
|
const twoStepLoading = ref(false);
|
|
|
|
|
const twoStepError = ref("");
|
|
|
|
|
|
|
|
|
|
async function fetchArtifactsTwoStep(runId: string) {
|
|
|
|
|
lvl1.value = [];
|
|
|
|
|
lvl2.value = [];
|
|
|
|
|
twoStepError.value = "";
|
|
|
|
|
if (!runId) return;
|
|
|
|
|
|
|
|
|
|
twoStepLoading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
// 1) 루트(=runId) 기준 1단계
|
|
|
|
|
const res1 = await MlflowService.artifact(runId);
|
|
|
|
|
const body1 = unwrapAxiosLike(res1) as MlflowArtifactList;
|
|
|
|
|
const firstFiles = (body1?.files ?? []).map((f) => ({
|
|
|
|
|
path: f.path,
|
|
|
|
|
is_dir: !!f.is_dir,
|
|
|
|
|
file_size: f.file_size,
|
|
|
|
|
})) as ArtifactRow[];
|
|
|
|
|
lvl1.value = firstFiles;
|
|
|
|
|
|
|
|
|
|
// 2) 1단계의 "디렉터리"에 대해 하위(=2단계) 조회
|
|
|
|
|
const dirPaths = firstFiles.filter((x) => x.is_dir).map((x) => x.path);
|
|
|
|
|
let merged: ArtifactRow[] = [];
|
|
|
|
|
if (dirPaths.length > 0) {
|
|
|
|
|
const results = await Promise.all(
|
|
|
|
|
dirPaths.map(async (p) => {
|
|
|
|
|
const r = await MlflowService.artifact(runId, p);
|
|
|
|
|
return unwrapAxiosLike(r) as MlflowArtifactList;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
for (const r of results) {
|
|
|
|
|
const files2 = (r?.files ?? []).map((f) => ({
|
|
|
|
|
path: f.path, // ex) "sklearn-model/MLmodel" 또는 "sklearn-model/sub"
|
|
|
|
|
is_dir: !!f.is_dir,
|
|
|
|
|
file_size: f.file_size,
|
|
|
|
|
})) as ArtifactRow[];
|
|
|
|
|
merged.push(...files2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3) 2단계 결과 중 "하위 폴더"가 있으면 그 하위(=3단계)도 한 번 더 조회 (lazy 없이 자동 1번만)
|
|
|
|
|
const subDirs = merged.filter((x) => x.is_dir);
|
|
|
|
|
if (subDirs.length > 0) {
|
|
|
|
|
const results3 = await Promise.all(
|
|
|
|
|
subDirs.map(async (sd) => {
|
|
|
|
|
const r3 = await MlflowService.artifact(runId, sd.path);
|
|
|
|
|
return unwrapAxiosLike(r3) as MlflowArtifactList;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
for (const r3 of results3) {
|
|
|
|
|
const files3 = (r3?.files ?? []).map((f) => ({
|
|
|
|
|
path: f.path, // ex) "sklearn-model/sub/deeper.txt"
|
|
|
|
|
is_dir: !!f.is_dir,
|
|
|
|
|
file_size: f.file_size,
|
|
|
|
|
})) as ArtifactRow[];
|
|
|
|
|
merged.push(...files3);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
lvl2.value = merged;
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
twoStepError.value =
|
|
|
|
|
e?.response?.data?.message || e?.message || "artifact 조회 실패";
|
|
|
|
|
console.error("[Artifacts][TwoStep] ERROR", e);
|
|
|
|
|
} finally {
|
|
|
|
|
twoStepLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ========= Helpers ========= */
|
|
|
|
|
const safeParse = <T = any,>(v: any): T | null => {
|
|
|
|
|
try {
|
|
|
|
|
@ -123,62 +238,28 @@ const fmtNumber = (v: number | null, digits = 3) => {
|
|
|
|
|
return v.toExponential(2);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeArray = (vals: (number | null)[]) => {
|
|
|
|
|
const xs = vals.filter((v): v is number => Number.isFinite(v as number));
|
|
|
|
|
if (xs.length === 0) return vals;
|
|
|
|
|
const min = Math.min(...xs),
|
|
|
|
|
max = Math.max(...xs);
|
|
|
|
|
if (max === min) return vals.map((v) => (v == null ? v : 1));
|
|
|
|
|
return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const labelOfRun = (r: RunDetailType) => r.info.run_name || r.info.run_id;
|
|
|
|
|
const valueOf = (run: RunDetailType, key: string): number | null => {
|
|
|
|
|
const m = run.data.metrics.find((x) => x.key === key)?.value;
|
|
|
|
|
return Number.isFinite(m as number) ? Number(m) : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* === File icon helpers (빨간 밑줄 원인: 누락/중복 정의 방지) === */
|
|
|
|
|
const fileExt = (name: string) => {
|
|
|
|
|
const n = (name || "").toLowerCase().trim();
|
|
|
|
|
const idx = n.lastIndexOf(".");
|
|
|
|
|
return idx >= 0 ? n.slice(idx + 1) : "";
|
|
|
|
|
};
|
|
|
|
|
const fileIconByName = (name: string) => {
|
|
|
|
|
const n = (name || "").toLowerCase().trim();
|
|
|
|
|
// exact filename first
|
|
|
|
|
if (n === "mlmodel") return "mdi-file-cog-outline";
|
|
|
|
|
if (n === "conda.yaml" || n === "conda.yml")
|
|
|
|
|
return "mdi-file-settings-outline";
|
|
|
|
|
if (n === "requirements.txt") return "mdi-file-code-outline";
|
|
|
|
|
// folder-ish (no dot or slash)
|
|
|
|
|
if (!n.includes(".") && !n.includes("/")) return "mdi-file-outline";
|
|
|
|
|
// by extension
|
|
|
|
|
switch (fileExt(n)) {
|
|
|
|
|
case "json":
|
|
|
|
|
return "mdi-code-json";
|
|
|
|
|
case "yml":
|
|
|
|
|
case "yaml":
|
|
|
|
|
return "mdi-code-braces";
|
|
|
|
|
case "txt":
|
|
|
|
|
return "mdi-file-document-outline";
|
|
|
|
|
case "pkl":
|
|
|
|
|
case "pickle":
|
|
|
|
|
return "mdi-cube-outline";
|
|
|
|
|
case "onnx":
|
|
|
|
|
return "mdi-robot-outline";
|
|
|
|
|
case "pt":
|
|
|
|
|
case "pth":
|
|
|
|
|
return "mdi-chip";
|
|
|
|
|
case "bin":
|
|
|
|
|
return "mdi-database-outline";
|
|
|
|
|
case "joblib":
|
|
|
|
|
case "pmdarima":
|
|
|
|
|
return "mdi-archive-outline";
|
|
|
|
|
default:
|
|
|
|
|
return "mdi-file-outline";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bytes = (n?: number) =>
|
|
|
|
|
typeof n === "number" && isFinite(n)
|
|
|
|
|
? n < 1024
|
|
|
|
|
? `${n} B`
|
|
|
|
|
: n < 1024 ** 2
|
|
|
|
|
? `${(n / 1024).toFixed(1)} KB`
|
|
|
|
|
: n < 1024 ** 3
|
|
|
|
|
? `${(n / 1024 ** 2).toFixed(1)} MB`
|
|
|
|
|
: `${(n / 1024 ** 3).toFixed(2)} GB`
|
|
|
|
|
: "—";
|
|
|
|
|
|
|
|
|
|
/* ========= Derived (computed) ========= */
|
|
|
|
|
const runItems = computed(() =>
|
|
|
|
|
@ -195,59 +276,7 @@ const selectedMetrics = computed<MetricKV[]>(() =>
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/* ===== Artifacts (from tag: mlflow.log-model.history) ===== */
|
|
|
|
|
const historyArtifacts = computed<any[]>(() => {
|
|
|
|
|
const tags = runDetail.value?.data?.tags;
|
|
|
|
|
let raw: string | undefined;
|
|
|
|
|
if (Array.isArray(tags))
|
|
|
|
|
raw = tags.find((t: any) => t?.key === "mlflow.log-model.history")?.value;
|
|
|
|
|
else if (tags && typeof tags === "object")
|
|
|
|
|
raw = tags["mlflow.log-model.history"];
|
|
|
|
|
const parsed = safeParse<any>(raw);
|
|
|
|
|
return parsed ? (Array.isArray(parsed) ? parsed : [parsed]) : [];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const artifactGroups = computed<ArtifactGroup[]>(() => {
|
|
|
|
|
const groups: ArtifactGroup[] = [];
|
|
|
|
|
for (const meta of historyArtifacts.value) {
|
|
|
|
|
const base = String(meta?.artifact_path ?? "");
|
|
|
|
|
if (!base) continue;
|
|
|
|
|
const flavors = meta.flavors ?? {};
|
|
|
|
|
const pf = flavors?.python_function ?? {};
|
|
|
|
|
const sk = flavors?.sklearn ?? {};
|
|
|
|
|
const files = new Set<string>();
|
|
|
|
|
files.add(`${base}/MLmodel`);
|
|
|
|
|
if (pf?.model_path) files.add(`${base}/${pf.model_path}`);
|
|
|
|
|
if (pf?.env?.conda) files.add(`${base}/${pf.env.conda}`);
|
|
|
|
|
if (pf?.env?.virtualenv) files.add(`${base}/${pf.env.virtualenv}`);
|
|
|
|
|
if (pf?.env?.requirements) files.add(`${base}/${pf.env.requirements}`);
|
|
|
|
|
if (sk?.pickled_model) files.add(`${base}/${sk.pickled_model}`);
|
|
|
|
|
const items: ArtifactRow[] = Array.from(files)
|
|
|
|
|
.sort((a, b) =>
|
|
|
|
|
a.endsWith("MLmodel")
|
|
|
|
|
? -1
|
|
|
|
|
: b.endsWith("MLmodel")
|
|
|
|
|
? 1
|
|
|
|
|
: a.localeCompare(b),
|
|
|
|
|
)
|
|
|
|
|
.map((p) => ({ path: p, is_dir: false }));
|
|
|
|
|
groups.push({ base, items });
|
|
|
|
|
}
|
|
|
|
|
return groups;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const historyArtifactMeta = computed(
|
|
|
|
|
() => historyArtifacts.value.at(-1) ?? null,
|
|
|
|
|
);
|
|
|
|
|
const historyArtifactPath = computed(() =>
|
|
|
|
|
String(historyArtifactMeta.value?.artifact_path ?? ""),
|
|
|
|
|
);
|
|
|
|
|
const artifactItems = computed<ArtifactRow[]>(() => {
|
|
|
|
|
const base = historyArtifactPath.value;
|
|
|
|
|
return artifactGroups.value.find((x) => x.base === base)?.items ?? [];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ========= URI 조합기 & 클릭 핸들러 ========= */
|
|
|
|
|
/* ========= URI 조합 & 다운로드/배포 ========= */
|
|
|
|
|
function buildArtifactUri(fullPath: string) {
|
|
|
|
|
const expId =
|
|
|
|
|
currentExperimentId.value ||
|
|
|
|
|
@ -257,12 +286,12 @@ function buildArtifactUri(fullPath: string) {
|
|
|
|
|
const runId = runDetail.value?.info?.run_id || selectedRunId.value || "";
|
|
|
|
|
return `${expId}/${runId}/artifacts/${fullPath}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onClickArtifact(fullPath: string) {
|
|
|
|
|
const objectName = buildArtifactUri(fullPath); // expId/runId/artifacts/...
|
|
|
|
|
const objectName = buildArtifactUri(fullPath);
|
|
|
|
|
try {
|
|
|
|
|
artifactsLoading.value = true;
|
|
|
|
|
const res = await MinioService.download(objectName);
|
|
|
|
|
// blob 다운로드 처리...
|
|
|
|
|
await MinioService.download(objectName);
|
|
|
|
|
} finally {
|
|
|
|
|
artifactsLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
@ -316,6 +345,7 @@ async function fetchRuns(expName?: string) {
|
|
|
|
|
exp?.experiment_id ?? exp?.experimentId ?? exp?.id ?? "",
|
|
|
|
|
);
|
|
|
|
|
currentExperimentId.value = expId;
|
|
|
|
|
|
|
|
|
|
if (!expId) {
|
|
|
|
|
runs.value = [];
|
|
|
|
|
selectedRunId.value = "";
|
|
|
|
|
@ -325,10 +355,8 @@ async function fetchRuns(expName?: string) {
|
|
|
|
|
const body = unwrapAxiosLike(await MlflowService.getRuns(expId));
|
|
|
|
|
const list =
|
|
|
|
|
body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []);
|
|
|
|
|
console.log("bodybody", body);
|
|
|
|
|
|
|
|
|
|
const matched = (Array.isArray(list) ? list : []).filter(
|
|
|
|
|
(r: any) => getTag(r, "kubeflow_run_id") === parentRunId,
|
|
|
|
|
(r: any) => getTag(r, "experiment_id") === parentRunId,
|
|
|
|
|
);
|
|
|
|
|
const final = matched.length > 0 ? matched : list;
|
|
|
|
|
const sorted = [...final].sort(
|
|
|
|
|
@ -470,6 +498,14 @@ function createTracesByRun(metricKeys: string[], runsData: RunDetailType[]) {
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const normalizeArray = (vals: (number | null)[]) => {
|
|
|
|
|
const xs = vals.filter((v): v is number => Number.isFinite(v as number));
|
|
|
|
|
if (xs.length === 0) return vals;
|
|
|
|
|
const min = Math.min(...xs),
|
|
|
|
|
max = Math.max(...xs);
|
|
|
|
|
if (max === min) return vals.map((v) => (v == null ? v : 1));
|
|
|
|
|
return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
|
|
|
|
|
};
|
|
|
|
|
function drawCompareChart() {
|
|
|
|
|
if (!elCompare.value) return;
|
|
|
|
|
const metricKeys = activeMetricKeys.value;
|
|
|
|
|
@ -484,33 +520,6 @@ function drawCompareChart() {
|
|
|
|
|
? createTracesByMetric(metricKeys, runsData)
|
|
|
|
|
: createTracesByRun(metricKeys, runsData);
|
|
|
|
|
|
|
|
|
|
if (compareChartMode.value === "byMetric") {
|
|
|
|
|
const varianceOrder = metricKeys
|
|
|
|
|
.map((k, idx) => {
|
|
|
|
|
const vals = traces
|
|
|
|
|
.map((t) => t.y[idx])
|
|
|
|
|
.filter((v: any) => v != null) as number[];
|
|
|
|
|
const min = Math.min(...vals),
|
|
|
|
|
max = Math.max(...vals);
|
|
|
|
|
return { k, spread: max - min };
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => b.spread - a.spread)
|
|
|
|
|
.map((v) => v.k);
|
|
|
|
|
|
|
|
|
|
traces.forEach((t) => {
|
|
|
|
|
t.x = varianceOrder;
|
|
|
|
|
t.y = varianceOrder.map((mk: string) => t.y[metricKeys.indexOf(mk)]);
|
|
|
|
|
if (t.text)
|
|
|
|
|
t.text = varianceOrder.map(
|
|
|
|
|
(mk: string) => t.text![metricKeys.indexOf(mk)],
|
|
|
|
|
);
|
|
|
|
|
if (t.customdata)
|
|
|
|
|
t.customdata = varianceOrder.map(
|
|
|
|
|
(mk: string) => t.customdata![metricKeys.indexOf(mk)],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Plotly.react(
|
|
|
|
|
elCompare.value,
|
|
|
|
|
traces,
|
|
|
|
|
@ -519,30 +528,7 @@ function drawCompareChart() {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ========= Compare Actions ========= */
|
|
|
|
|
function openCompareDialog() {
|
|
|
|
|
compareSelectedRunIds.value = Array.from(
|
|
|
|
|
new Set([selectedRunId.value].filter(Boolean)),
|
|
|
|
|
);
|
|
|
|
|
compareSelectedMetricKeys.value = [];
|
|
|
|
|
compareDialog.value = true;
|
|
|
|
|
}
|
|
|
|
|
async function loadCompareData() {
|
|
|
|
|
if (!compareDialog.value) return;
|
|
|
|
|
compareLoading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await Promise.all(compareSelectedRunIds.value.map(ensureRunDetail));
|
|
|
|
|
if (compareSelectedMetricKeys.value.length === 0) {
|
|
|
|
|
compareSelectedMetricKeys.value = commonMetricKeys.value.slice(0, 6);
|
|
|
|
|
}
|
|
|
|
|
await nextTick();
|
|
|
|
|
drawCompareChart();
|
|
|
|
|
} finally {
|
|
|
|
|
compareLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ========= Derived for Compare ========= */
|
|
|
|
|
/* ========= Compare Derived/Actions ========= */
|
|
|
|
|
const compareRuns = computed<RunDetailType[]>(
|
|
|
|
|
() =>
|
|
|
|
|
compareSelectedRunIds.value
|
|
|
|
|
@ -565,6 +551,28 @@ const activeMetricKeys = computed(() =>
|
|
|
|
|
: commonMetricKeys.value,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
function openCompareDialog() {
|
|
|
|
|
compareSelectedRunIds.value = Array.from(
|
|
|
|
|
new Set([selectedRunId.value].filter(Boolean)),
|
|
|
|
|
);
|
|
|
|
|
compareSelectedMetricKeys.value = [];
|
|
|
|
|
compareDialog.value = true;
|
|
|
|
|
}
|
|
|
|
|
async function loadCompareData() {
|
|
|
|
|
if (!compareDialog.value) return;
|
|
|
|
|
compareLoading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await Promise.all(compareSelectedRunIds.value.map(ensureRunDetail));
|
|
|
|
|
if (compareSelectedMetricKeys.value.length === 0) {
|
|
|
|
|
compareSelectedMetricKeys.value = commonMetricKeys.value.slice(0, 6);
|
|
|
|
|
}
|
|
|
|
|
await nextTick();
|
|
|
|
|
drawCompareChart();
|
|
|
|
|
} finally {
|
|
|
|
|
compareLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ========= Auth ========= */
|
|
|
|
|
function restoreAuthFromStorage() {
|
|
|
|
|
const raw = localStorage.getItem(AUTH_KEY);
|
|
|
|
|
@ -607,7 +615,6 @@ const handleLogin = async () => {
|
|
|
|
|
loginError.value = "로그인에 실패했습니다. 아이디/비밀번호를 확인하세요.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toSave: ExternalAuth = {
|
|
|
|
|
id: payload.id ?? id,
|
|
|
|
|
name: payload.name ?? id,
|
|
|
|
|
@ -631,22 +638,18 @@ const handleLogin = async () => {
|
|
|
|
|
|
|
|
|
|
/* ========= Deployment Modal ========= */
|
|
|
|
|
const openDeploymentModal = async (fullPath?: string) => {
|
|
|
|
|
// 1) 경로가 들어오면 즉시 URI 조합/저장
|
|
|
|
|
if (fullPath) {
|
|
|
|
|
const uri = buildArtifactUri(fullPath);
|
|
|
|
|
lastArtifactUri.value = uri;
|
|
|
|
|
pendingArtifactPath.value = uri;
|
|
|
|
|
}
|
|
|
|
|
// 2) 비로그인 → 로그인 우선
|
|
|
|
|
if (!isAuthenticated.value) {
|
|
|
|
|
loginDialog.value = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// 3) 로그인 후 pending 값 복원
|
|
|
|
|
if (!fullPath && pendingArtifactPath.value)
|
|
|
|
|
lastArtifactUri.value = pendingArtifactPath.value;
|
|
|
|
|
|
|
|
|
|
// 4) 모달 열고 패키지 로딩
|
|
|
|
|
isEditVisible.value = true;
|
|
|
|
|
packagesError.value = "";
|
|
|
|
|
packageOptions.value = [];
|
|
|
|
|
@ -668,7 +671,6 @@ const openDeploymentModal = async (fullPath?: string) => {
|
|
|
|
|
try {
|
|
|
|
|
packagesLoading.value = true;
|
|
|
|
|
const res = await ExternalAuthControllerService.search(auth.id, auth.token);
|
|
|
|
|
|
|
|
|
|
const body = res?.data ?? res;
|
|
|
|
|
const list = body?.data?.data ?? body?.data ?? body?.result ?? body ?? [];
|
|
|
|
|
const arr = Array.isArray(list) ? list : [];
|
|
|
|
|
@ -688,11 +690,10 @@ const closeCreateModal = () => {
|
|
|
|
|
isEditVisible.value = false;
|
|
|
|
|
};
|
|
|
|
|
const saveData = (payload: any) => {
|
|
|
|
|
// 필요 시 서버에 저장/리프레시 등 처리
|
|
|
|
|
console.log("[DeploymentDialog payload]", payload);
|
|
|
|
|
// 완료 후 모달 닫기
|
|
|
|
|
isEditVisible.value = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ========= Timeline (fallback) ========= */
|
|
|
|
|
const rawHistory = computed<any[]>(() => {
|
|
|
|
|
const h =
|
|
|
|
|
@ -746,10 +747,12 @@ watch(
|
|
|
|
|
() => mainTab.value,
|
|
|
|
|
async (t) => {
|
|
|
|
|
if (t === "viz" || t === "artifacts") await refreshIfActive();
|
|
|
|
|
if (t === "artifacts" && selectedRunId.value) {
|
|
|
|
|
await fetchArtifactsTwoStep(selectedRunId.value);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => props.experimentInfo,
|
|
|
|
|
async () => {
|
|
|
|
|
@ -757,7 +760,12 @@ watch(
|
|
|
|
|
await refreshIfActive();
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
watch(selectedRunId, (id) => fetchRunDetail(id));
|
|
|
|
|
watch(selectedRunId, async (id) => {
|
|
|
|
|
fetchRunDetail(id);
|
|
|
|
|
if (mainTab.value === "artifacts" && id) {
|
|
|
|
|
await fetchArtifactsTwoStep(id);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
watch(vizTab, async (t) => {
|
|
|
|
|
if (mainTab.value === "viz" && t === "metrics") {
|
|
|
|
|
await nextTick();
|
|
|
|
|
@ -968,7 +976,6 @@ const artifactsLoading = ref(false);
|
|
|
|
|
clear-icon=""
|
|
|
|
|
style="min-width: 280px; max-width: 440px"
|
|
|
|
|
/>
|
|
|
|
|
<!-- Compare button -->
|
|
|
|
|
<v-btn
|
|
|
|
|
color="primary"
|
|
|
|
|
variant="elevated"
|
|
|
|
|
@ -1130,9 +1137,8 @@ const artifactsLoading = ref(false);
|
|
|
|
|
<v-card flat class="mb-6">
|
|
|
|
|
<v-card-title
|
|
|
|
|
class="py-2 px-0 text-button text-medium-emphasis"
|
|
|
|
|
>Model Metrics (selected run)</v-card-title
|
|
|
|
|
>
|
|
|
|
|
Model Metrics (selected run)
|
|
|
|
|
</v-card-title>
|
|
|
|
|
<v-table density="comfortable">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
@ -1161,9 +1167,8 @@ const artifactsLoading = ref(false);
|
|
|
|
|
<v-card flat class="mb-6">
|
|
|
|
|
<v-card-title
|
|
|
|
|
class="py-2 px-0 text-button text-medium-emphasis"
|
|
|
|
|
>Metrics (bar chart)</v-card-title
|
|
|
|
|
>
|
|
|
|
|
Metrics (bar chart)
|
|
|
|
|
</v-card-title>
|
|
|
|
|
<div
|
|
|
|
|
ref="elMetrics"
|
|
|
|
|
style="width: 100%; height: 400px"
|
|
|
|
|
@ -1173,19 +1178,19 @@ const artifactsLoading = ref(false);
|
|
|
|
|
</v-window-item>
|
|
|
|
|
|
|
|
|
|
<v-window-item value="scatter">
|
|
|
|
|
<v-card-text class="px-6 py-10 text-medium-emphasis">
|
|
|
|
|
(준비중) X/Y 축 선택 후 산점도 표시
|
|
|
|
|
</v-card-text>
|
|
|
|
|
<v-card-text class="px-6 py-10 text-medium-emphasis"
|
|
|
|
|
>(준비중) X/Y 축 선택 후 산점도 표시</v-card-text
|
|
|
|
|
>
|
|
|
|
|
</v-window-item>
|
|
|
|
|
<v-window-item value="box">
|
|
|
|
|
<v-card-text class="px-6 py-10 text-medium-emphasis">
|
|
|
|
|
(준비중) 메트릭 분포 Box Plot
|
|
|
|
|
</v-card-text>
|
|
|
|
|
<v-card-text class="px-6 py-10 text-medium-emphasis"
|
|
|
|
|
>(준비중) 메트릭 분포 Box Plot</v-card-text
|
|
|
|
|
>
|
|
|
|
|
</v-window-item>
|
|
|
|
|
<v-window-item value="contour">
|
|
|
|
|
<v-card-text class="px-6 py-10 text-medium-emphasis">
|
|
|
|
|
(준비중) 2D/3D Contour Plot
|
|
|
|
|
</v-card-text>
|
|
|
|
|
<v-card-text class="px-6 py-10 text-medium-emphasis"
|
|
|
|
|
>(준비중) 2D/3D Contour Plot</v-card-text
|
|
|
|
|
>
|
|
|
|
|
</v-window-item>
|
|
|
|
|
</v-window>
|
|
|
|
|
|
|
|
|
|
@ -1195,7 +1200,7 @@ const artifactsLoading = ref(false);
|
|
|
|
|
</v-card>
|
|
|
|
|
</v-window-item>
|
|
|
|
|
|
|
|
|
|
<!-- ========= Artifacts ========= -->
|
|
|
|
|
<!-- ========= Artifacts (Two-step, Flat) ========= -->
|
|
|
|
|
<v-window-item value="artifacts">
|
|
|
|
|
<v-card class="rounded-lg pa-8 w-100">
|
|
|
|
|
<v-card-text>
|
|
|
|
|
@ -1221,8 +1226,16 @@ const artifactsLoading = ref(false);
|
|
|
|
|
style="min-width: 280px; max-width: 440px"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- 로그인 상태 표시 -->
|
|
|
|
|
<div class="d-flex align-center ga-2 ml-auto">
|
|
|
|
|
<v-btn
|
|
|
|
|
size="small"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
:loading="twoStepLoading"
|
|
|
|
|
@click="fetchArtifactsTwoStep(selectedRunId)"
|
|
|
|
|
>
|
|
|
|
|
Refresh
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<v-chip
|
|
|
|
|
v-if="isAuthenticated"
|
|
|
|
|
color="success"
|
|
|
|
|
@ -1233,7 +1246,6 @@ const artifactsLoading = ref(false);
|
|
|
|
|
<v-icon start size="16">mdi-check-decagram</v-icon>
|
|
|
|
|
{{ externalAuth?.name || externalAuth?.id }}
|
|
|
|
|
</v-chip>
|
|
|
|
|
|
|
|
|
|
<v-btn
|
|
|
|
|
v-if="isAuthenticated"
|
|
|
|
|
size="small"
|
|
|
|
|
@ -1254,7 +1266,7 @@ const artifactsLoading = ref(false);
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<v-progress-circular
|
|
|
|
|
v-if="artifactsLoading || loadingRuns || loadingRunDetail"
|
|
|
|
|
v-if="twoStepLoading || loadingRuns || loadingRunDetail"
|
|
|
|
|
indeterminate
|
|
|
|
|
size="16"
|
|
|
|
|
class="ml-2"
|
|
|
|
|
@ -1264,157 +1276,114 @@ const artifactsLoading = ref(false);
|
|
|
|
|
</v-row>
|
|
|
|
|
|
|
|
|
|
<v-alert
|
|
|
|
|
v-if="!historyArtifactPath"
|
|
|
|
|
type="info"
|
|
|
|
|
v-if="twoStepError"
|
|
|
|
|
type="error"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
class="mb-4"
|
|
|
|
|
class="mb-3"
|
|
|
|
|
>
|
|
|
|
|
이 실행에서 <code>mlflow.log-model.history</code> 태그를 찾을 수
|
|
|
|
|
없어요.
|
|
|
|
|
{{ twoStepError }}
|
|
|
|
|
</v-alert>
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
<div class="text-body-2 mb-2">
|
|
|
|
|
Path:
|
|
|
|
|
<v-chip size="small" variant="tonal" class="ml-1">{{
|
|
|
|
|
historyArtifactPath
|
|
|
|
|
}}</v-chip>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<v-card variant="tonal">
|
|
|
|
|
<v-card-title class="py-2 px-4">Artifacts</v-card-title>
|
|
|
|
|
<v-divider />
|
|
|
|
|
|
|
|
|
|
<v-card-text class="px-0">
|
|
|
|
|
<!-- 히스토리에 아무것도 없을 때 -->
|
|
|
|
|
<v-alert
|
|
|
|
|
v-if="!loadingRunDetail && artifactGroups.length === 0"
|
|
|
|
|
type="info"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
class="ma-3"
|
|
|
|
|
density="comfortable"
|
|
|
|
|
>
|
|
|
|
|
No files
|
|
|
|
|
</v-alert>
|
|
|
|
|
|
|
|
|
|
<!-- 여러 폴더 -->
|
|
|
|
|
<v-list
|
|
|
|
|
v-else-if="artifactGroups.length > 1"
|
|
|
|
|
lines="one"
|
|
|
|
|
density="comfortable"
|
|
|
|
|
class="file-list"
|
|
|
|
|
>
|
|
|
|
|
<template v-for="(g, gi) in artifactGroups" :key="g.base">
|
|
|
|
|
<v-list-subheader class="d-flex align-center">
|
|
|
|
|
<v-icon class="mr-2">mdi-folder</v-icon>
|
|
|
|
|
{{ g.base }}
|
|
|
|
|
</v-list-subheader>
|
|
|
|
|
|
|
|
|
|
<v-list-item
|
|
|
|
|
v-for="(f, i) in g.items"
|
|
|
|
|
:key="f.path"
|
|
|
|
|
:ripple="false"
|
|
|
|
|
class="file-row pl-8"
|
|
|
|
|
<v-card variant="tonal">
|
|
|
|
|
<v-card-title class="py-2 px-4">Artifacts</v-card-title>
|
|
|
|
|
<v-divider />
|
|
|
|
|
<v-card-text class="px-0">
|
|
|
|
|
<v-table density="comfortable">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th style="width: 48px"></th>
|
|
|
|
|
<th>Path</th>
|
|
|
|
|
<th style="width: 160px" class="text-right">Size</th>
|
|
|
|
|
<th style="width: 180px" class="text-right">Actions</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-if="twoStepLoading">
|
|
|
|
|
<td
|
|
|
|
|
colspan="4"
|
|
|
|
|
class="text-center py-6 text-medium-emphasis"
|
|
|
|
|
>
|
|
|
|
|
<template #prepend>
|
|
|
|
|
<v-icon
|
|
|
|
|
:icon="
|
|
|
|
|
fileIconByName(f.path.split('/').pop() || '')
|
|
|
|
|
"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<v-list-item-title class="file-name">
|
|
|
|
|
{{ f.path.replace(g.base + "/", "") }}
|
|
|
|
|
</v-list-item-title>
|
|
|
|
|
|
|
|
|
|
<v-list-item-subtitle class="file-size">
|
|
|
|
|
{{ (f.file_size ?? 0).toLocaleString() }} bytes
|
|
|
|
|
</v-list-item-subtitle>
|
|
|
|
|
|
|
|
|
|
<template #append>
|
|
|
|
|
<IconDownloadBtn
|
|
|
|
|
@onClick="onClickArtifact(f.path)"
|
|
|
|
|
/>
|
|
|
|
|
<IconDeployBtn
|
|
|
|
|
class="ml-2"
|
|
|
|
|
:tooltip="
|
|
|
|
|
isAuthenticated ? 'Deploy' : 'Login required'
|
|
|
|
|
"
|
|
|
|
|
@onClick="openDeploymentModal(f.path)"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
</v-list-item>
|
|
|
|
|
|
|
|
|
|
<v-divider
|
|
|
|
|
v-if="gi < artifactGroups.length - 1"
|
|
|
|
|
class="my-2"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
</v-list>
|
|
|
|
|
Loading…
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
<!-- 폴더 1개 -->
|
|
|
|
|
<v-list
|
|
|
|
|
v-else
|
|
|
|
|
lines="one"
|
|
|
|
|
density="comfortable"
|
|
|
|
|
class="file-list"
|
|
|
|
|
>
|
|
|
|
|
<v-list-subheader class="d-flex align-center">
|
|
|
|
|
<v-icon class="mr-2">mdi-folder</v-icon>
|
|
|
|
|
{{ historyArtifactPath }}
|
|
|
|
|
</v-list-subheader>
|
|
|
|
|
|
|
|
|
|
<template v-for="(f, i) in artifactItems" :key="f.path">
|
|
|
|
|
<v-list-item :ripple="false" class="file-row pl-8">
|
|
|
|
|
<template #prepend>
|
|
|
|
|
<v-icon
|
|
|
|
|
:icon="
|
|
|
|
|
fileIconByName(
|
|
|
|
|
historyArtifactPath &&
|
|
|
|
|
f.path.startsWith(historyArtifactPath + '/')
|
|
|
|
|
? f.path.slice(
|
|
|
|
|
historyArtifactPath.length + 1,
|
|
|
|
|
)
|
|
|
|
|
: f.path,
|
|
|
|
|
)
|
|
|
|
|
"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<v-list-item-title class="file-name">
|
|
|
|
|
{{
|
|
|
|
|
historyArtifactPath &&
|
|
|
|
|
f.path.startsWith(historyArtifactPath + "/")
|
|
|
|
|
? f.path.slice(historyArtifactPath.length + 1)
|
|
|
|
|
: f.path
|
|
|
|
|
}}
|
|
|
|
|
</v-list-item-title>
|
|
|
|
|
|
|
|
|
|
<v-list-item-subtitle class="file-size">
|
|
|
|
|
{{ (f.file_size ?? 0).toLocaleString() }} bytes
|
|
|
|
|
</v-list-item-subtitle>
|
|
|
|
|
|
|
|
|
|
<template #append>
|
|
|
|
|
<IconDownloadBtn
|
|
|
|
|
@onClick="onClickArtifact(f.path)"
|
|
|
|
|
/>
|
|
|
|
|
<IconDeployBtn
|
|
|
|
|
class="ml-2"
|
|
|
|
|
:tooltip="
|
|
|
|
|
isAuthenticated ? 'Deploy' : 'Login required'
|
|
|
|
|
"
|
|
|
|
|
@onClick="openDeploymentModal(f.path)"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
</v-list-item>
|
|
|
|
|
|
|
|
|
|
<v-divider v-if="i < artifactItems.length - 1" />
|
|
|
|
|
<template v-else>
|
|
|
|
|
<tr v-if="!artifactGroups.length">
|
|
|
|
|
<td
|
|
|
|
|
colspan="4"
|
|
|
|
|
class="text-center py-6 text-medium-emphasis"
|
|
|
|
|
>
|
|
|
|
|
No Artifacts
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
<!-- 디렉터리 헤더 -->
|
|
|
|
|
<template v-for="grp in artifactGroups" :key="grp.dir">
|
|
|
|
|
<tr class="group-row">
|
|
|
|
|
<td class="text-center">
|
|
|
|
|
<v-icon>mdi-folder</v-icon>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<strong>{{ grp.dir }}</strong>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="text-right"></td>
|
|
|
|
|
<td />
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
<!-- 하위(폴더/파일) -->
|
|
|
|
|
<tr
|
|
|
|
|
v-for="(it, idx) in grp.files"
|
|
|
|
|
:key="grp.dir + '-' + idx"
|
|
|
|
|
>
|
|
|
|
|
<!-- 아이콘 전용 칸 제거, 경로 칸이 아이콘 칸까지 흡수 -->
|
|
|
|
|
<td colspan="2">
|
|
|
|
|
<div
|
|
|
|
|
class="path-cell"
|
|
|
|
|
:style="{
|
|
|
|
|
paddingLeft: `${18 * (1 + (it.depth ?? 0))}px`,
|
|
|
|
|
}"
|
|
|
|
|
>
|
|
|
|
|
<v-icon
|
|
|
|
|
:icon="it.is_dir ? 'mdi-folder' : FILE_ICON"
|
|
|
|
|
size="18"
|
|
|
|
|
class="mr-2"
|
|
|
|
|
/>
|
|
|
|
|
<code class="truncate">{{ it.path }}</code>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
<td class="text-right">
|
|
|
|
|
{{ bytes(it.file_size) }}
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
<td class="text-right">
|
|
|
|
|
<template v-if="!it.is_dir">
|
|
|
|
|
<IconDownloadBtn
|
|
|
|
|
@onClick="onClickArtifact(it.path)"
|
|
|
|
|
/>
|
|
|
|
|
<IconDeployBtn
|
|
|
|
|
class="ml-2"
|
|
|
|
|
:tooltip="
|
|
|
|
|
isAuthenticated
|
|
|
|
|
? 'Deploy'
|
|
|
|
|
: 'Login required'
|
|
|
|
|
"
|
|
|
|
|
@onClick="openDeploymentModal(it.path)"
|
|
|
|
|
/>
|
|
|
|
|
</template>
|
|
|
|
|
<template v-else>
|
|
|
|
|
<v-btn size="small" variant="text" @click="">
|
|
|
|
|
Open
|
|
|
|
|
</v-btn>
|
|
|
|
|
</template>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</template>
|
|
|
|
|
</template>
|
|
|
|
|
</v-list>
|
|
|
|
|
</v-card-text>
|
|
|
|
|
</v-card>
|
|
|
|
|
</template>
|
|
|
|
|
</tbody>
|
|
|
|
|
</v-table>
|
|
|
|
|
</v-card-text>
|
|
|
|
|
</v-card>
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
|
|
|
|
<v-sheet class="d-flex justify-end mb-2">
|
|
|
|
|
@ -1438,6 +1407,7 @@ const artifactsLoading = ref(false);
|
|
|
|
|
/>
|
|
|
|
|
</v-container>
|
|
|
|
|
|
|
|
|
|
<!-- 배포 다이얼로그 -->
|
|
|
|
|
<v-dialog v-model="isEditVisible" max-width="800" persistent>
|
|
|
|
|
<DeploymentDialog
|
|
|
|
|
:edit-data="null"
|
|
|
|
|
@ -1452,6 +1422,7 @@ const artifactsLoading = ref(false);
|
|
|
|
|
:user-option="[]"
|
|
|
|
|
/>
|
|
|
|
|
</v-dialog>
|
|
|
|
|
|
|
|
|
|
<!-- 로그인 모달 -->
|
|
|
|
|
<v-dialog v-model="loginDialog" max-width="450" persistent>
|
|
|
|
|
<v-card>
|
|
|
|
|
@ -1494,9 +1465,7 @@ const artifactsLoading = ref(false);
|
|
|
|
|
@keyup.enter.prevent="handleLogin"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="loginError" class="mt-3 text-error">
|
|
|
|
|
{{ loginError }}
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="loginError" class="mt-3 text-error">{{ loginError }}</div>
|
|
|
|
|
</v-form>
|
|
|
|
|
</v-card-text>
|
|
|
|
|
<v-card-actions class="justify-end" style="padding: 16px 24px">
|
|
|
|
|
@ -1515,4 +1484,10 @@ const artifactsLoading = ref(false);
|
|
|
|
|
:root {
|
|
|
|
|
--dot-size: 28px;
|
|
|
|
|
}
|
|
|
|
|
.group-row {
|
|
|
|
|
background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
}
|
|
|
|
|
.child-path {
|
|
|
|
|
padding-left: 18px; /* 들여쓰기로 디렉터리 소속임을 표시 */
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|