feat(export-import): add yaml export/import with id upsert for credentials, snippets, and scenarios
- all entities export a kind field (credential/snippet/scenario) for safe type checking on import - import upserts by id: overwrites if id exists, creates with explicit id otherwise - scenario export now includes id and step ids; import deletes old steps before recreating - add GET /:id/export and POST /import endpoints to credential and snippet controllers - add UuidBadge ui component: shortens uuid to first+last 4 hex chars, tooltip, clipboard copy with animated ClipboardCheck icon pop - apply UuidBadge across all entity id display sites (detail pages, card footers, table columns) - add export/import buttons to CredentialsPage, SnippetsPage, CredentialDetailPage, SnippetDetailPage
This commit is contained in:
+2
-1
@@ -20,7 +20,8 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.14.0"
|
||||
"react-router-dom": "^7.14.0",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
+55
-28
@@ -39,7 +39,7 @@ export const credentials = {
|
||||
list(page = 1, limit = 50): Promise<PaginatedResponse<Credential>> {
|
||||
return request(`/credentials?page=${page}&limit=${limit}`);
|
||||
},
|
||||
get(id: number): Promise<Credential> {
|
||||
get(id: string): Promise<Credential> {
|
||||
return request(`/credentials/${id}`);
|
||||
},
|
||||
create(name: string, data?: string): Promise<Credential> {
|
||||
@@ -48,15 +48,24 @@ export const credentials = {
|
||||
body: JSON.stringify({ name, data }),
|
||||
});
|
||||
},
|
||||
update(id: number, patch: Partial<Pick<Credential, 'name' | 'data'>>): Promise<Credential> {
|
||||
update(id: string, patch: Partial<Pick<Credential, 'name' | 'data'>>): Promise<Credential> {
|
||||
return request(`/credentials/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
},
|
||||
remove(id: number): Promise<void> {
|
||||
remove(id: string): Promise<void> {
|
||||
return request(`/credentials/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
exportCredential(id: string): Promise<unknown> {
|
||||
return request(`/credentials/${id}/export`);
|
||||
},
|
||||
importCredential(payload: unknown): Promise<Credential> {
|
||||
return request('/credentials/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ── Snippets ──────────────────────────────────────────────────────────────────
|
||||
@@ -65,7 +74,7 @@ export const snippets = {
|
||||
list(page = 1, limit = 50): Promise<PaginatedResponse<Snippet>> {
|
||||
return request(`/snippets?page=${page}&limit=${limit}`);
|
||||
},
|
||||
get(id: number): Promise<Snippet> {
|
||||
get(id: string): Promise<Snippet> {
|
||||
return request(`/snippets/${id}`);
|
||||
},
|
||||
create(payload: Pick<Snippet, 'name' | 'code'> & { description?: string }): Promise<Snippet> {
|
||||
@@ -74,15 +83,24 @@ export const snippets = {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
update(id: number, patch: Partial<Pick<Snippet, 'name' | 'description' | 'code'>>): Promise<Snippet> {
|
||||
update(id: string, patch: Partial<Pick<Snippet, 'name' | 'description' | 'code'>>): Promise<Snippet> {
|
||||
return request(`/snippets/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
},
|
||||
remove(id: number): Promise<void> {
|
||||
remove(id: string): Promise<void> {
|
||||
return request(`/snippets/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
exportSnippet(id: string): Promise<unknown> {
|
||||
return request(`/snippets/${id}/export`);
|
||||
},
|
||||
importSnippet(payload: unknown): Promise<Snippet> {
|
||||
return request('/snippets/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ── Environments ──────────────────────────────────────────────────────────────
|
||||
@@ -91,7 +109,7 @@ export const environments = {
|
||||
list(page = 1, limit = 50): Promise<PaginatedResponse<Environment>> {
|
||||
return request(`/environments?page=${page}&limit=${limit}`);
|
||||
},
|
||||
get(id: number): Promise<Environment> {
|
||||
get(id: string): Promise<Environment> {
|
||||
return request(`/environments/${id}`);
|
||||
},
|
||||
create(name: string, urls: Environment['urls']): Promise<Environment> {
|
||||
@@ -100,13 +118,13 @@ export const environments = {
|
||||
body: JSON.stringify({ name, urls }),
|
||||
});
|
||||
},
|
||||
update(id: number, patch: Partial<Pick<Environment, 'name' | 'urls'>>): Promise<Environment> {
|
||||
update(id: string, patch: Partial<Pick<Environment, 'name' | 'urls'>>): Promise<Environment> {
|
||||
return request(`/environments/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
},
|
||||
remove(id: number): Promise<void> {
|
||||
remove(id: string): Promise<void> {
|
||||
return request(`/environments/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
@@ -125,10 +143,10 @@ export const sessions = {
|
||||
list(page = 1, limit = 50): Promise<PaginatedResponse<Session>> {
|
||||
return request(`/sessions?page=${page}&limit=${limit}`);
|
||||
},
|
||||
get(id: number): Promise<Session> {
|
||||
get(id: string): Promise<Session> {
|
||||
return request(`/sessions/${id}`);
|
||||
},
|
||||
remove(id: number): Promise<void> {
|
||||
remove(id: string): Promise<void> {
|
||||
return request(`/sessions/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
@@ -139,7 +157,7 @@ export const scenarios = {
|
||||
list(page = 1, limit = 50): Promise<PaginatedResponse<Scenario>> {
|
||||
return request(`/scenarios?page=${page}&limit=${limit}`);
|
||||
},
|
||||
get(id: number): Promise<Scenario & { steps: ScenarioStep[] }> {
|
||||
get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
|
||||
return request(`/scenarios/${id}`);
|
||||
},
|
||||
create(name: string): Promise<Scenario> {
|
||||
@@ -148,41 +166,50 @@ export const scenarios = {
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
update(id: number, patch: Partial<Pick<Scenario, 'name'>>): Promise<Scenario> {
|
||||
update(id: string, patch: Partial<Pick<Scenario, 'name'>>): Promise<Scenario> {
|
||||
return request(`/scenarios/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
},
|
||||
remove(id: number): Promise<void> {
|
||||
remove(id: string): Promise<void> {
|
||||
return request(`/scenarios/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
run(id: number): Promise<ScenarioRun> {
|
||||
run(id: string): Promise<ScenarioRun> {
|
||||
return request(`/scenarios/${id}/run`, { method: 'POST' });
|
||||
},
|
||||
getRun(scenarioId: number, runId: number): Promise<ScenarioRunDetail> {
|
||||
getRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
|
||||
return request(`/scenarios/${scenarioId}/run/${runId}`);
|
||||
},
|
||||
waitForRun(scenarioId: number, runId: number): Promise<ScenarioRunDetail> {
|
||||
waitForRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
|
||||
return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' });
|
||||
},
|
||||
listRuns(scenarioId: number, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||
listRuns(scenarioId: string, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
||||
},
|
||||
exportScenario(id: string): Promise<unknown> {
|
||||
return request(`/scenarios/${id}/export`);
|
||||
},
|
||||
importScenario(payload: unknown): Promise<Scenario> {
|
||||
return request('/scenarios/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ── Runs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const runs = {
|
||||
listAll(page = 1, limit = 20, status?: string): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: number; name: string } }>> {
|
||||
listAll(page = 1, limit = 20, status?: string): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } }>> {
|
||||
const q = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (status) q.set('status', status);
|
||||
return request(`/scenarios/runs?${q}`);
|
||||
},
|
||||
list(scenarioId: number, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||
list(scenarioId: string, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
||||
},
|
||||
get(scenarioId: number, runId: number): Promise<ScenarioRunDetail> {
|
||||
get(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
|
||||
return request(`/scenarios/${scenarioId}/run/${runId}`);
|
||||
},
|
||||
};
|
||||
@@ -190,16 +217,16 @@ export const runs = {
|
||||
// ── Scenario Credentials ──────────────────────────────────────────────────────
|
||||
|
||||
export const scenarioCredentials = {
|
||||
list(scenarioId: number): Promise<ScenarioCredential[]> {
|
||||
list(scenarioId: string): Promise<ScenarioCredential[]> {
|
||||
return request(`/scenarios/${scenarioId}/credentials`);
|
||||
},
|
||||
add(scenarioId: number, credentialId: number, alias: string): Promise<ScenarioCredential> {
|
||||
add(scenarioId: string, credentialId: string, alias: string): Promise<ScenarioCredential> {
|
||||
return request(`/scenarios/${scenarioId}/credentials`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ credentialId, alias }),
|
||||
});
|
||||
},
|
||||
remove(scenarioId: number, scCredId: number): Promise<void> {
|
||||
remove(scenarioId: string, scCredId: string): Promise<void> {
|
||||
return request(`/scenarios/${scenarioId}/credentials/${scCredId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
@@ -224,22 +251,22 @@ export interface UpdateStepPayload {
|
||||
}
|
||||
|
||||
export const steps = {
|
||||
get(scenarioId: number, stepId: number): Promise<ScenarioStep> {
|
||||
get(scenarioId: string, stepId: string): Promise<ScenarioStep> {
|
||||
return request(`/scenarios/${scenarioId}/steps/${stepId}`);
|
||||
},
|
||||
create(scenarioId: number, payload: CreateStepPayload): Promise<ScenarioStep> {
|
||||
create(scenarioId: string, payload: CreateStepPayload): Promise<ScenarioStep> {
|
||||
return request(`/scenarios/${scenarioId}/steps`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
update(scenarioId: number, stepId: number, payload: UpdateStepPayload): Promise<ScenarioStep> {
|
||||
update(scenarioId: string, stepId: string, payload: UpdateStepPayload): Promise<ScenarioStep> {
|
||||
return request(`/scenarios/${scenarioId}/steps/${stepId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
remove(scenarioId: number, stepId: number): Promise<void> {
|
||||
remove(scenarioId: string, stepId: string): Promise<void> {
|
||||
return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
+18
-18
@@ -17,7 +17,7 @@ export interface EnvironmentUrls {
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
urls: EnvironmentUrls;
|
||||
createdAt: string;
|
||||
@@ -27,7 +27,7 @@ export interface Environment {
|
||||
// ── Credentials ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Credential {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
data: string | null;
|
||||
lastUsedAt: string | null;
|
||||
@@ -38,7 +38,7 @@ export interface Credential {
|
||||
// ── Snippets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Snippet {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
code: string;
|
||||
@@ -57,7 +57,7 @@ export interface KeysResponse {
|
||||
export type SessionStatus = 'open' | 'closed';
|
||||
|
||||
export interface Session {
|
||||
id: number;
|
||||
id: string;
|
||||
sessionName: string;
|
||||
token: string;
|
||||
status: SessionStatus;
|
||||
@@ -71,8 +71,8 @@ export interface Session {
|
||||
export type StepType = 'login' | 'exec' | 'sign';
|
||||
|
||||
export interface ScenarioStep {
|
||||
id: number;
|
||||
scenarioId: number;
|
||||
id: string;
|
||||
scenarioId: string;
|
||||
order: number;
|
||||
type: StepType;
|
||||
title: string | null;
|
||||
@@ -84,16 +84,16 @@ export interface ScenarioStep {
|
||||
}
|
||||
|
||||
export interface ScenarioCredential {
|
||||
id: number;
|
||||
scenarioId: number;
|
||||
credentialId: number;
|
||||
id: string;
|
||||
scenarioId: string;
|
||||
credentialId: string;
|
||||
alias: string;
|
||||
credential: Credential;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
steps?: ScenarioStep[];
|
||||
scenarioCredentials?: ScenarioCredential[];
|
||||
@@ -108,8 +108,8 @@ export type RunStepStatus = 'waiting' | 'pending' | 'in_progress' | 'pass' | 'fa
|
||||
export type LogLevel = 'log' | 'warn' | 'error';
|
||||
|
||||
export interface ScenarioRun {
|
||||
id: number;
|
||||
scenarioId: number;
|
||||
id: string;
|
||||
scenarioId: string;
|
||||
scenario?: Pick<Scenario, 'id' | 'name'>;
|
||||
status: ScenarioRunStatus;
|
||||
stepRuns?: ScenarioRunStep[];
|
||||
@@ -118,9 +118,9 @@ export interface ScenarioRun {
|
||||
}
|
||||
|
||||
export interface ScenarioRunStep {
|
||||
id: number;
|
||||
runId: number;
|
||||
scenarioStepId: number;
|
||||
id: string;
|
||||
runId: string;
|
||||
scenarioStepId: string;
|
||||
scenarioStep: ScenarioStep;
|
||||
status: RunStepStatus;
|
||||
order: number;
|
||||
@@ -131,9 +131,9 @@ export interface ScenarioRunStep {
|
||||
}
|
||||
|
||||
export interface ScenarioRunLog {
|
||||
id: number;
|
||||
runId: number;
|
||||
stepRunId: number | null;
|
||||
id: string;
|
||||
runId: string;
|
||||
stepRunId: string | null;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -71,7 +71,10 @@
|
||||
"field_last_used": "Last Used",
|
||||
"field_created": "Created",
|
||||
"field_updated": "Updated",
|
||||
"section_data": "Data"
|
||||
"section_data": "Data",
|
||||
"action_export": "Export",
|
||||
"action_import": "Import",
|
||||
"import_error": "Failed to import credential"
|
||||
},
|
||||
"keys": {
|
||||
"col_name": "Key name",
|
||||
@@ -116,6 +119,9 @@
|
||||
"action_add": "New scenario",
|
||||
"action_edit": "Edit",
|
||||
"action_runs": "Runs",
|
||||
"action_export": "Export",
|
||||
"action_import": "Import",
|
||||
"import_error": "Failed to import scenario",
|
||||
"action_save": "Create",
|
||||
"action_update": "Save changes",
|
||||
"action_cancel": "Cancel",
|
||||
@@ -228,6 +234,9 @@
|
||||
"field_description": "Description",
|
||||
"field_created": "Created",
|
||||
"field_updated": "Updated",
|
||||
"section_code": "Code"
|
||||
"section_code": "Code",
|
||||
"action_export": "Export",
|
||||
"action_import": "Import",
|
||||
"import_error": "Failed to import snippet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Download, Pencil, Trash2 } from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { credentials } from '../../api';
|
||||
import type { Credential } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function CredentialDetailPage() {
|
||||
@@ -18,7 +19,7 @@ export function CredentialDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
credentials
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then(setCredential)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -30,6 +31,18 @@ export function CredentialDetailPage() {
|
||||
navigate('/credentials');
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!credential) return;
|
||||
const data = await credentials.exportCredential(credential.id);
|
||||
const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `credential-${credential.name}.yaml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.pageToolbar}>
|
||||
@@ -41,6 +54,10 @@ export function CredentialDetailPage() {
|
||||
/>
|
||||
{credential && (
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||
<Download size={14} />
|
||||
{t('credentials.action_export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -74,7 +91,7 @@ export function CredentialDetailPage() {
|
||||
<DescriptionList
|
||||
layout="comfortable"
|
||||
items={[
|
||||
{ term: t('credentials.field_id'), detail: credential.id },
|
||||
{ term: t('credentials.field_id'), detail: <UuidBadge id={credential.id} /> },
|
||||
{
|
||||
term: t('credentials.field_last_used'),
|
||||
detail: credential.lastUsedAt ? (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import { Settings, Pencil, Trash2, Plus, Upload } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { credentials } from '../../api';
|
||||
import type { Credential } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
function CredentialCard({
|
||||
@@ -12,7 +13,7 @@ function CredentialCard({
|
||||
onDelete,
|
||||
}: {
|
||||
credential: Credential;
|
||||
onDelete: (id: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -48,7 +49,7 @@ function CredentialCard({
|
||||
|
||||
const footer = (
|
||||
<div className={styles.envCardFooter}>
|
||||
<span className={styles.envCardId}>#{credential.id}</span>
|
||||
<UuidBadge id={credential.id} />
|
||||
<Timestamp value={credential.updatedAt} />
|
||||
</div>
|
||||
);
|
||||
@@ -90,9 +91,11 @@ function AddCredentialCard() {
|
||||
|
||||
export function CredentialsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = () => {
|
||||
credentials
|
||||
@@ -106,14 +109,43 @@ export function CredentialsPage() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleDelete = async (id: string) => {
|
||||
await credentials.remove(id);
|
||||
setItems((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
try {
|
||||
const text = await file.text();
|
||||
const payload = yamlParse(text) as unknown;
|
||||
const imported = await credentials.importCredential(payload);
|
||||
navigate(`/credentials/${imported.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: t('credentials.title') }]} className={styles.breadcrumbs} />
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs items={[{ label: t('credentials.title') }]} className={styles.breadcrumbs} />
|
||||
<div className={styles.toolbarActions}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".yaml,.yml,.json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImport}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={14} />
|
||||
{t('credentials.action_import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading ? (
|
||||
<p className={styles.muted}>{t('credentials.loading')}</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function EditCredentialPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
credentials
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then((c) => {
|
||||
setCredential(c);
|
||||
setName(c.name);
|
||||
@@ -52,7 +52,7 @@ export function EditCredentialPage() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await credentials.update(Number(id), {
|
||||
await credentials.update(id!, {
|
||||
name: name.trim(),
|
||||
data: data.trim() || null,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export function EditEnvironmentPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
environments
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then((data) => {
|
||||
setEnv(data);
|
||||
setName(data.name);
|
||||
@@ -45,7 +45,7 @@ export function EditEnvironmentPage() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await environments.update(Number(id), {
|
||||
await environments.update(id!, {
|
||||
name: name.trim(),
|
||||
urls: {
|
||||
id_url: idUrl.trim() || undefined,
|
||||
|
||||
@@ -18,7 +18,7 @@ export function EnvironmentDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
environments
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then(setEnv)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Settings, ExternalLink, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import { environments } from '../../api';
|
||||
import type { Environment } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: number) => void }) {
|
||||
function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const urlEntries = Object.entries(env.urls).filter(([, v]) => v);
|
||||
@@ -48,7 +48,7 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n
|
||||
|
||||
const footer = (
|
||||
<div className={styles.envCardFooter}>
|
||||
<span className={styles.envCardId}>#{env.id}</span>
|
||||
<UuidBadge id={env.id} />
|
||||
<Timestamp value={env.updatedAt} />
|
||||
</div>
|
||||
);
|
||||
@@ -106,7 +106,7 @@ export function EnvironmentsPage() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleDelete = async (id: string) => {
|
||||
await environments.remove(id);
|
||||
setItems((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { runs } from '../../api';
|
||||
import type { ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api';
|
||||
import { AutoRefreshIndicator, Badge, Table, Timestamp, type TableColumn } from '../../ui';
|
||||
import { AutoRefreshIndicator, Badge, Table, Timestamp, UuidBadge, type TableColumn } from '../../ui';
|
||||
import type { BadgeVariant } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
@@ -57,7 +57,7 @@ export function AllRunsPage() {
|
||||
}, []);
|
||||
|
||||
const columns: TableColumn<AllRunRow>[] = [
|
||||
{ key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 },
|
||||
{ key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 },
|
||||
{
|
||||
key: 'scenario',
|
||||
header: t('runs.col_scenario'),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Notification,
|
||||
Table,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
type TableColumn,
|
||||
} from '../../ui';
|
||||
import type { BadgeVariant } from '../../ui';
|
||||
@@ -82,7 +83,7 @@ export function RunDetailPage() {
|
||||
if (!id || !runId) return;
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all([scenarios.get(Number(id)), runs.get(Number(id), Number(runId))])
|
||||
Promise.all([scenarios.get(id), runs.get(id, runId)])
|
||||
.then(([sc, r]) => {
|
||||
if (cancelled) return;
|
||||
setScenario(sc);
|
||||
@@ -91,7 +92,7 @@ export function RunDetailPage() {
|
||||
setPolling(true);
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await runs.get(Number(id), Number(runId));
|
||||
const updated = await runs.get(id, runId);
|
||||
if (cancelled) return;
|
||||
setRun(updated);
|
||||
setPulseKey((k) => k + 1);
|
||||
@@ -183,7 +184,7 @@ export function RunDetailPage() {
|
||||
<DescriptionList
|
||||
layout="comfortable"
|
||||
items={[
|
||||
{ term: t('runs.field_id'), detail: run.id },
|
||||
{ term: t('runs.field_id'), detail: <UuidBadge id={run.id} /> },
|
||||
{
|
||||
term: t('runs.field_status'),
|
||||
detail: (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Play } from 'lucide-react';
|
||||
import { scenarios, runs } from '../../api';
|
||||
import type { Scenario, ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api';
|
||||
import { AutoRefreshIndicator, Badge, Breadcrumbs, Button, Table, Timestamp, type TableColumn } from '../../ui';
|
||||
import { AutoRefreshIndicator, Badge, Breadcrumbs, Button, Table, Timestamp, UuidBadge, type TableColumn } from '../../ui';
|
||||
import type { BadgeVariant } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
@@ -39,7 +39,7 @@ export function RunsPage() {
|
||||
const load = useCallback(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
Promise.all([scenarios.get(Number(id)), runs.list(Number(id))])
|
||||
Promise.all([scenarios.get(id!), runs.list(id!)])
|
||||
.then(([sc, res]) => {
|
||||
setScenario(sc);
|
||||
setItems(res.data);
|
||||
@@ -58,12 +58,12 @@ export function RunsPage() {
|
||||
}, [load]);
|
||||
|
||||
const handleRun = async () => {
|
||||
const run = await scenarios.run(Number(id));
|
||||
const run = await scenarios.run(id!);
|
||||
navigate(`/scenarios/${id}/runs/${run.id}`);
|
||||
};
|
||||
|
||||
const columns: TableColumn<RunRow>[] = [
|
||||
{ key: 'id', header: t('runs.col_id'), render: (r) => r.id, width: 60 },
|
||||
{ key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 },
|
||||
{
|
||||
key: 'status',
|
||||
header: t('runs.col_status'),
|
||||
|
||||
@@ -26,7 +26,7 @@ export function CreateStepPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
scenarios.get(Number(id)).then(setScenario).catch(() => null);
|
||||
scenarios.get(id).then(setScenario).catch(() => null);
|
||||
}, [id]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
@@ -42,7 +42,7 @@ export function CreateStepPage() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await steps.create(Number(id), {
|
||||
await steps.create(id!, {
|
||||
order: Number(order),
|
||||
title: title.trim() || undefined,
|
||||
type,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function EditScenarioPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
scenarios
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then((data) => {
|
||||
setScenario(data);
|
||||
setName(data.name);
|
||||
@@ -39,7 +39,7 @@ export function EditScenarioPage() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await scenarios.update(Number(id), { name: name.trim() });
|
||||
await scenarios.update(id!, { name: name.trim() });
|
||||
navigate(`/scenarios/${id}`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function EditStepPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !stepId) return;
|
||||
Promise.all([scenarios.get(Number(id)), steps.get(Number(id), Number(stepId))])
|
||||
Promise.all([scenarios.get(id), steps.get(id, stepId)])
|
||||
.then(([sc, st]) => {
|
||||
setScenario(sc);
|
||||
setStep(st);
|
||||
@@ -56,7 +56,7 @@ export function EditStepPage() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await steps.update(Number(id), Number(stepId), {
|
||||
await steps.update(id!, stepId!, {
|
||||
order: Number(order),
|
||||
title: title.trim() || undefined,
|
||||
type,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Pencil, Plus, History, Trash2 } from 'lucide-react';
|
||||
import { Play, Pencil, Plus, History, Trash2, Download } from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
||||
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Select,
|
||||
Table,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
type TableColumn,
|
||||
} from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
@@ -46,7 +48,7 @@ export function ScenarioDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
scenarios
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then((s) => {
|
||||
setScenario(s);
|
||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||
@@ -71,8 +73,20 @@ export function ScenarioDetailPage() {
|
||||
navigate('/scenarios');
|
||||
};
|
||||
|
||||
const handleDeleteStep = async (stepId: number) => {
|
||||
await steps.remove(Number(id), stepId);
|
||||
const handleExport = async () => {
|
||||
if (!scenario) return;
|
||||
const data = await scenarios.exportScenario(scenario.id);
|
||||
const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${scenario.name}.yaml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDeleteStep = async (stepId: string) => {
|
||||
await steps.remove(id!, stepId);
|
||||
setScenario((prev) =>
|
||||
prev ? { ...prev, steps: prev.steps.filter((s) => s.id !== stepId) } : prev,
|
||||
);
|
||||
@@ -86,9 +100,9 @@ export function ScenarioDetailPage() {
|
||||
if (!valid) return;
|
||||
setAddCredSaving(true);
|
||||
try {
|
||||
const sc = await scenarioCredentials.add(Number(id), Number(addCredId), addAlias.trim());
|
||||
const sc = await scenarioCredentials.add(id!, addCredId, addAlias.trim());
|
||||
// Re-fetch the credential object since the response may not include it
|
||||
const full = await scenarioCredentials.list(Number(id));
|
||||
const full = await scenarioCredentials.list(id!);
|
||||
setScenarioCreds(full);
|
||||
void sc;
|
||||
setAddCredOpen(false);
|
||||
@@ -101,8 +115,8 @@ export function ScenarioDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCredential = async (scCredId: number) => {
|
||||
await scenarioCredentials.remove(Number(id), scCredId);
|
||||
const handleRemoveCredential = async (scCredId: string) => {
|
||||
await scenarioCredentials.remove(id!, scCredId);
|
||||
setScenarioCreds((prev) => prev.filter((sc) => sc.id !== scCredId));
|
||||
};
|
||||
|
||||
@@ -212,6 +226,10 @@ export function ScenarioDetailPage() {
|
||||
<History size={14} />
|
||||
{t('scenarios.action_runs')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||
<Download size={14} />
|
||||
{t('scenarios.action_export')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
|
||||
<Pencil size={14} />
|
||||
{t('scenarios.action_edit')}
|
||||
@@ -240,7 +258,7 @@ export function ScenarioDetailPage() {
|
||||
<DescriptionList
|
||||
layout="comfortable"
|
||||
items={[
|
||||
{ term: t('scenarios.field_id'), detail: scenario.id },
|
||||
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
|
||||
{ term: t('scenarios.field_name'), detail: scenario.name },
|
||||
{ term: t('scenarios.field_steps'), detail: scenario.steps.length },
|
||||
{
|
||||
@@ -296,7 +314,8 @@ export function ScenarioDetailPage() {
|
||||
</div>
|
||||
|
||||
{addCredOpen && (
|
||||
<Card className={styles.formCard} style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<Card className={styles.formCard}>
|
||||
<form onSubmit={handleAddCredential} noValidate>
|
||||
<div className={styles.formFields}>
|
||||
<Select
|
||||
@@ -332,6 +351,7 @@ export function ScenarioDetailPage() {
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scenarioCreds.length > 0 ? (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Plus, Trash2 } from 'lucide-react';
|
||||
import { Play, Plus, Trash2, Upload } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { scenarios } from '../../api';
|
||||
import type { Scenario } from '../../api';
|
||||
import { Breadcrumbs, Button, Table, type TableColumn, Timestamp } from '../../ui';
|
||||
import { Breadcrumbs, Button, Table, type TableColumn, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function ScenariosPage() {
|
||||
@@ -14,6 +15,8 @@ export function ScenariosPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
scenarios
|
||||
.list()
|
||||
@@ -26,19 +29,33 @@ export function ScenariosPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleRun = async (id: number) => {
|
||||
const handleRun = async (id: string) => {
|
||||
const run = await scenarios.run(id);
|
||||
navigate(`/scenarios/${id}/runs/${run.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleDelete = async (id: string) => {
|
||||
await scenarios.remove(id);
|
||||
setLoading(true);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
try {
|
||||
const text = await file.text();
|
||||
const payload = yamlParse(text) as unknown;
|
||||
const imported = await scenarios.importScenario(payload);
|
||||
navigate(`/scenarios/${imported.id}`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableColumn<Scenario>[] = [
|
||||
{ key: 'id', header: t('scenarios.col_id'), render: (s) => s.id, width: 60 },
|
||||
{ key: 'id', header: t('scenarios.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
|
||||
{ key: 'name', header: t('scenarios.col_name'), render: (s) => s.name },
|
||||
{
|
||||
key: 'updated',
|
||||
@@ -74,6 +91,17 @@ export function ScenariosPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs items={[{ label: t('scenarios.title') }]} className={styles.breadcrumbs} />
|
||||
<div className={styles.toolbarActions}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".yaml,.yml,.json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImport}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={14} />
|
||||
{t('scenarios.action_import')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate('/scenarios/new')}>
|
||||
<Plus size={14} />
|
||||
{t('scenarios.action_add')}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { sessions } from '../../api';
|
||||
import type { Session } from '../../api';
|
||||
import { Badge, Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp } from '../../ui';
|
||||
import { Badge, Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function SessionDetailPage() {
|
||||
@@ -18,7 +18,7 @@ export function SessionDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
sessions
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then(setSession)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -62,7 +62,7 @@ export function SessionDetailPage() {
|
||||
<DescriptionList
|
||||
layout="comfortable"
|
||||
items={[
|
||||
{ term: t('sessions.field_id'), detail: session.id },
|
||||
{ term: t('sessions.field_id'), detail: <UuidBadge id={session.id} /> },
|
||||
{ term: t('sessions.field_name'), detail: session.sessionName },
|
||||
{
|
||||
term: t('sessions.field_status'),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { sessions } from '../../api';
|
||||
import type { Session } from '../../api';
|
||||
import { Badge, Breadcrumbs, Button, Table, type TableColumn, Timestamp } from '../../ui';
|
||||
import { Badge, Breadcrumbs, Button, Table, type TableColumn, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function SessionsPage() {
|
||||
@@ -26,14 +26,14 @@ export function SessionsPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleDelete = async (id: string) => {
|
||||
await sessions.remove(id);
|
||||
setLoading(true);
|
||||
load();
|
||||
};
|
||||
|
||||
const columns: TableColumn<Session>[] = [
|
||||
{ key: 'id', header: t('sessions.col_id'), render: (s) => s.id, width: 60 },
|
||||
{ key: 'id', header: t('sessions.col_id'), render: (s) => <UuidBadge id={s.id} />, width: 60 },
|
||||
{ key: 'name', header: t('sessions.col_name'), render: (s) => s.sessionName },
|
||||
{
|
||||
key: 'status',
|
||||
|
||||
@@ -24,7 +24,7 @@ export function EditSnippetPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
snippets
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then((s) => {
|
||||
setSnippet(s);
|
||||
setName(s.name);
|
||||
@@ -50,7 +50,7 @@ export function EditSnippetPage() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await snippets.update(Number(id), {
|
||||
await snippets.update(id!, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
code: code.trim(),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Download, Pencil, Trash2 } from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { snippets } from '../../api';
|
||||
import type { Snippet } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function SnippetDetailPage() {
|
||||
@@ -18,7 +19,7 @@ export function SnippetDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
snippets
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then(setSnippet)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -30,6 +31,18 @@ export function SnippetDetailPage() {
|
||||
navigate('/snippets');
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!snippet) return;
|
||||
const data = await snippets.exportSnippet(snippet.id);
|
||||
const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `snippet-${snippet.name}.yaml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.pageToolbar}>
|
||||
@@ -41,6 +54,10 @@ export function SnippetDetailPage() {
|
||||
/>
|
||||
{snippet && (
|
||||
<div className={styles.toolbarActions}>
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||
<Download size={14} />
|
||||
{t('snippets.action_export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -74,7 +91,7 @@ export function SnippetDetailPage() {
|
||||
<DescriptionList
|
||||
layout="comfortable"
|
||||
items={[
|
||||
{ term: t('snippets.field_id'), detail: snippet.id },
|
||||
{ term: t('snippets.field_id'), detail: <UuidBadge id={snippet.id} /> },
|
||||
{ term: t('snippets.field_name'), detail: snippet.name },
|
||||
{
|
||||
term: t('snippets.field_description'),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Code, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import { Code, Pencil, Trash2, Plus, Upload } from 'lucide-react';
|
||||
import { parse as yamlParse } from 'yaml';
|
||||
import { snippets } from '../../api';
|
||||
import type { Snippet } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, ContextMenu, Timestamp } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, ContextMenu, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
function SnippetCard({
|
||||
@@ -12,7 +13,7 @@ function SnippetCard({
|
||||
onDelete,
|
||||
}: {
|
||||
snippet: Snippet;
|
||||
onDelete: (id: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -48,7 +49,7 @@ function SnippetCard({
|
||||
|
||||
const footer = (
|
||||
<div className={styles.envCardFooter}>
|
||||
<span className={styles.envCardId}>#{snippet.id}</span>
|
||||
<UuidBadge id={snippet.id} />
|
||||
<Timestamp value={snippet.updatedAt} />
|
||||
</div>
|
||||
);
|
||||
@@ -83,9 +84,11 @@ function AddSnippetCard() {
|
||||
|
||||
export function SnippetsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Snippet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = () => {
|
||||
snippets
|
||||
@@ -99,15 +102,42 @@ export function SnippetsPage() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleDelete = async (id: string) => {
|
||||
await snippets.remove(id);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
try {
|
||||
const text = await file.text();
|
||||
const payload = yamlParse(text) as unknown;
|
||||
const imported = await snippets.importSnippet(payload);
|
||||
navigate(`/snippets/${imported.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs items={[{ label: t('snippets.title') }]} />
|
||||
<div className={styles.toolbarActions}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".yaml,.yml,.json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImport}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload size={14} />
|
||||
{t('snippets.action_import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.pop {
|
||||
position: absolute;
|
||||
left: calc(100% + 4px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-success-fg);
|
||||
animation: popFade 1s ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes popFade {
|
||||
0% { opacity: 0; transform: translateY(calc(-50% - 4px)); }
|
||||
20% { opacity: 1; transform: translateY(-50%); }
|
||||
70% { opacity: 1; transform: translateY(-50%); }
|
||||
100% { opacity: 0; transform: translateY(calc(-50% - 4px)); }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
import { Badge } from '../Badge/Badge';
|
||||
import styles from './UuidBadge.module.css';
|
||||
|
||||
export interface UuidBadgeProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function UuidBadge({ id }: UuidBadgeProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const short = `${id.slice(0, 4)}…${id.slice(-4)}`;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(id).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={styles.wrapper} title={copied ? 'Copied!' : id}>
|
||||
<Badge variant="neutral">
|
||||
<button type="button" className={styles.button} onClick={handleClick}>
|
||||
{short}
|
||||
</button>
|
||||
</Badge>
|
||||
{copied && (
|
||||
<span className={styles.pop} aria-hidden="true">
|
||||
<ClipboardCheck size={13} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -44,3 +44,6 @@ export type { NotificationProps, NotificationVariant } from './Notification/Noti
|
||||
|
||||
export { Table } from './Table/Table';
|
||||
export type { TableProps, TableColumn } from './Table/Table';
|
||||
|
||||
export { UuidBadge } from './UuidBadge/UuidBadge';
|
||||
export type { UuidBadgeProps } from './UuidBadge/UuidBadge';
|
||||
|
||||
Reference in New Issue
Block a user