Loading portal-gui/src/app/(app)/my-applications/ProfileCard.tsx +3 −3 Original line number Diff line number Diff line Loading @@ -37,8 +37,8 @@ export const ProfileCard = ({ app, onDelete }: Props) => { } }; const externalPorts = app.componentSpec.flatMap((c) => c.networkInterfaces.map((n) => `${n.protocol}:${n.port}`) const externalPorts = (app.componentSpec ?? []).flatMap((c) => (c.networkInterfaces ?? []).map((n) => `${n.protocol}:${n.port}`) ); return ( Loading Loading @@ -92,7 +92,7 @@ export const ProfileCard = ({ app, onDelete }: Props) => { <div className={styles.field}> <dt>Components</dt> <dd>{app.componentSpec.map((c) => c.componentName).join(", ")}</dd> <dd>{(app.componentSpec ?? []).map((c) => c.componentName).join(", ")}</dd> </div> {externalPorts.length > 0 && ( Loading portal-gui/src/app/(app)/my-applications/page.tsx +13 −3 Original line number Diff line number Diff line Loading @@ -8,12 +8,14 @@ import { IApplicationProfile, MyApplicationsTabKey } from "../../utils/interface import { tabs } from "../../utils/constants"; import { deployments } from "../../utils/tableHelpers"; import { DeploymentCard } from "./DeploymentCard"; import { CreateProfileModal } from "../../components/CreateProfileModal/CreateProfileModal"; const page = () => { const [activeTab, setActiveTab] = useState<MyApplicationsTabKey>("profile"); const [apps, setApps] = useState<IApplicationProfile[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [createOpen, setCreateOpen] = useState(false); useEffect(() => { if (activeTab !== "profile") return; Loading @@ -38,7 +40,7 @@ const page = () => { <div className={styles.box}> <div className={styles.btnBox}> {activeTab === "profile" && ( <Button className={buttons.primary}>+ Create New</Button> <Button className={buttons.primary} onClick={() => setCreateOpen(true)}>+ Create New</Button> )} </div> Loading @@ -51,9 +53,9 @@ const page = () => { <p className={styles.status}>No applications registered yet.</p> )} <div className={styles.cards}> {apps.map((app) => ( {apps.map((app, i) => ( <ProfileCard key={app.appId} key={app.appId ?? i} app={app} onDelete={(id) => setApps((prev) => prev.filter((a) => a.appId !== id))} /> Loading @@ -71,6 +73,14 @@ const page = () => { </div> )} </div> <CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} onCreated={(app) => { setApps((prev) => [...prev, app]); setCreateOpen(false); }} /> </div> ); }; Loading portal-gui/src/app/api/apps/route.ts +28 −1 Original line number Diff line number Diff line import { NextResponse } from "next/server"; import { oeg } from "@/app/utils/constants"; export async function GET() { export async function POST(req: Request) { try { const body = await req.json(); const res = await fetch(`${oeg.baseUrl}/apps`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), cache: "no-store", }); if (!res.ok) { const err = await res.json().catch(() => ({})); return NextResponse.json( { error: err.message ?? `OEG returned ${res.status}` }, { status: res.status } ); } const data = await res.json(); return NextResponse.json(data, { status: 201 }); } catch { return NextResponse.json( { error: "Could not reach OEG" }, { status: 502 } ); } } export async function GET() { try { const res = await fetch(`${oeg.baseUrl}/apps`, { cache: "no-store", }); Loading portal-gui/src/app/components/CreateProfileModal/CreateProfileModal.tsx 0 → 100644 +357 −0 Original line number Diff line number Diff line "use client"; import { useState } from "react"; import { Modal, Button, Form, SelectPicker, Divider, Toggle } from "rsuite"; import { IApplicationProfile } from "@/app/utils/interfaces"; import styles from "./createProfileModal.module.scss"; import buttons from "@/app/styles/buttons.module.scss"; type InterfaceForm = { interfaceId: string; protocol: "TCP" | "UDP" | "ANY"; port: string; visibilityType: "VISIBILITY_EXTERNAL" | "VISIBILITY_INTERNAL"; }; type ComponentForm = { componentName: string; networkInterfaces: InterfaceForm[]; }; type FormState = { name: string; version: string; packageType: "HELM" | "CONTAINER" appProvider: string; repoType: "PUBLICREPO" | "PRIVATEREPO"; imagePath: string; userName: string; credentials: string; authType: "DOCKER" | "HTTP_BASIC" | "HTTP_BEARER" | "NONE"; componentSpec: ComponentForm[]; }; const defaultInterface = (): InterfaceForm => ({ interfaceId: "", protocol: "TCP", port: "80", visibilityType: "VISIBILITY_EXTERNAL", }); const defaultComponent = (): ComponentForm => ({ componentName: "", networkInterfaces: [defaultInterface()], }); const initialForm = (): FormState => ({ name: "", version: "", packageType: "HELM", appProvider: "", repoType: "PUBLICREPO", imagePath: "", userName: "", credentials: "", authType: "NONE", componentSpec: [defaultComponent()], }); const packageTypeOptions = ["HELM", "CONTAINER", "QCOW2", "OVA"].map((v) => ({ label: v, value: v })); const repoTypeOptions = ["PUBLICREPO", "PRIVATEREPO"].map((v) => ({ label: v, value: v })); const protocolOptions = ["TCP", "UDP", "ANY"].map((v) => ({ label: v, value: v })); const visibilityOptions = [ { label: "External", value: "VISIBILITY_EXTERNAL" }, { label: "Internal", value: "VISIBILITY_INTERNAL" }, ]; const authTypeOptions = ["NONE", "DOCKER", "HTTP_BASIC", "HTTP_BEARER"].map((v) => ({ label: v, value: v })); type Props = { open: boolean; onClose: () => void; onCreated: (app: IApplicationProfile) => void; }; export const CreateProfileModal = ({ open, onClose, onCreated }: Props) => { const [form, setForm] = useState<FormState>(initialForm); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState<string | null>(null); const set = <K extends keyof FormState>(key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value })); // ── Component helpers ── const setComponent = (i: number, key: keyof ComponentForm, value: string) => setForm((prev) => { const spec = [...prev.componentSpec]; spec[i] = { ...spec[i], [key]: value }; return { ...prev, componentSpec: spec }; }); const setInterface = ( ci: number, ii: number, key: keyof InterfaceForm, value: string ) => setForm((prev) => { const spec = [...prev.componentSpec]; const ifaces = [...spec[ci].networkInterfaces]; ifaces[ii] = { ...ifaces[ii], [key]: value }; spec[ci] = { ...spec[ci], networkInterfaces: ifaces }; return { ...prev, componentSpec: spec }; }); const addInterface = (ci: number) => setForm((prev) => { const spec = [...prev.componentSpec]; spec[ci] = { ...spec[ci], networkInterfaces: [...spec[ci].networkInterfaces, defaultInterface()] }; return { ...prev, componentSpec: spec }; }); const removeInterface = (ci: number, ii: number) => setForm((prev) => { const spec = [...prev.componentSpec]; spec[ci] = { ...spec[ci], networkInterfaces: spec[ci].networkInterfaces.filter((_, idx) => idx !== ii) }; return { ...prev, componentSpec: spec }; }); const addComponent = () => setForm((prev) => ({ ...prev, componentSpec: [...prev.componentSpec, defaultComponent()] })); const removeComponent = (i: number) => setForm((prev) => ({ ...prev, componentSpec: prev.componentSpec.filter((_, idx) => idx !== i) })); const handleSubmit = async () => { setError(null); setSubmitting(true); const body: Record<string, unknown> = { name: form.name, version: form.version, packageType: form.packageType, appRepo: { type: form.repoType, imagePath: form.imagePath, ...(form.repoType === "PRIVATEREPO" && { ...(form.userName && { userName: form.userName }), ...(form.credentials && { credentials: form.credentials }), ...(form.authType !== "NONE" && { authType: form.authType }), }), }, componentSpec: form.componentSpec.map((c) => ({ componentName: c.componentName, networkInterfaces: c.networkInterfaces.map((n) => ({ interfaceId: n.interfaceId, protocol: n.protocol, port: parseInt(n.port, 10), visibilityType: n.visibilityType, })), })), ...(form.appProvider && { appProvider: form.appProvider }), }; try { const res = await fetch("/api/apps", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (res.ok) { const created: IApplicationProfile = await res.json(); onCreated(created); setForm(initialForm()); onClose(); } else { const data = await res.json().catch(() => ({})); setError(data.error ?? "Failed to create application."); } } catch { setError("Could not reach the server."); } finally { setSubmitting(false); } }; const isValid = form.name && form.version && form.imagePath && form.componentSpec.length > 0 && form.componentSpec.every( (c) => c.componentName && c.networkInterfaces.length > 0 && c.networkInterfaces.every((n) => n.interfaceId && n.port) ); return ( <Modal open={open} onClose={onClose} size="md"> <Modal.Header> <Modal.Title>Register New Application</Modal.Title> </Modal.Header> <Modal.Body className={styles.body}> <Form fluid> <p className={styles.sectionLabel}>Basic info</p> <div className={styles.row}> <Form.Group className={styles.grow}> <Form.ControlLabel>App name <span className={styles.req}>*</span></Form.ControlLabel> <Form.Control name="name" value={form.name} onChange={(v) => set("name", v)} placeholder="my-app" /> </Form.Group> <Form.Group className={styles.fixed}> <Form.ControlLabel>Version <span className={styles.req}>*</span></Form.ControlLabel> <Form.Control name="version" value={form.version} onChange={(v) => set("version", v)} placeholder="1.0.0" /> </Form.Group> <Form.Group className={styles.fixed}> <Form.ControlLabel>Package type <span className={styles.req}>*</span></Form.ControlLabel> <SelectPicker data={packageTypeOptions} value={form.packageType} onChange={(v) => set("packageType", v as FormState["packageType"])} cleanable={false} block /> </Form.Group> </div> <Form.Group> <Form.ControlLabel>App provider <span className={styles.opt}>(optional)</span></Form.ControlLabel> <Form.Control name="appProvider" value={form.appProvider} onChange={(v) => set("appProvider", v)} placeholder="e.g. nginx_inc" /> </Form.Group> <Divider /> <p className={styles.sectionLabel}>Repository</p> <div className={styles.row}> <Form.Group className={styles.fixed}> <Form.ControlLabel>Type <span className={styles.req}>*</span></Form.ControlLabel> <SelectPicker data={repoTypeOptions} value={form.repoType} onChange={(v) => set("repoType", v as FormState["repoType"])} cleanable={false} block /> </Form.Group> <Form.Group className={styles.grow}> <Form.ControlLabel>Image path <span className={styles.req}>*</span></Form.ControlLabel> <Form.Control name="imagePath" value={form.imagePath} onChange={(v) => set("imagePath", v)} placeholder="https://..." /> </Form.Group> </div> {/* Private repo extras */} {form.repoType === "PRIVATEREPO" && ( <div className={styles.privateBox}> <div className={styles.row}> <Form.Group className={styles.grow}> <Form.ControlLabel>Username <span className={styles.opt}>(optional)</span></Form.ControlLabel> <Form.Control name="userName" value={form.userName} onChange={(v) => set("userName", v)} /> </Form.Group> <Form.Group className={styles.grow}> <Form.ControlLabel>Credentials / token <span className={styles.opt}>(optional)</span></Form.ControlLabel> <Form.Control name="credentials" value={form.credentials} onChange={(v) => set("credentials", v)} type="password" /> </Form.Group> <Form.Group className={styles.fixed}> <Form.ControlLabel>Auth type</Form.ControlLabel> <SelectPicker data={authTypeOptions} value={form.authType} onChange={(v) => set("authType", v as FormState["authType"])} cleanable={false} block /> </Form.Group> </div> </div> )} <Divider /> <div className={styles.sectionRow}> <p className={styles.sectionLabel}>Components <span className={styles.req}>*</span></p> <button className={styles.addBtn} onClick={addComponent}>+ Add component</button> </div> {form.componentSpec.map((comp, ci) => ( <div key={ci} className={styles.componentBox}> <div className={styles.componentHeader}> <Form.Group className={styles.grow}> <Form.ControlLabel>Component name <span className={styles.req}>*</span></Form.ControlLabel> <Form.Control name={`comp-${ci}`} value={comp.componentName} onChange={(v) => setComponent(ci, "componentName", v)} placeholder="e.g. web-server" /> </Form.Group> {form.componentSpec.length > 1 && ( <button className={styles.removeBtn} onClick={() => removeComponent(ci)}>Remove</button> )} </div> <p className={styles.ifaceLabel}>Network interfaces</p> {comp.networkInterfaces.map((iface, ii) => ( <div key={ii} className={styles.ifaceRow}> <Form.Control name={`iface-id-${ci}-${ii}`} value={iface.interfaceId} onChange={(v) => setInterface(ci, ii, "interfaceId", v)} placeholder="Interface ID *" className={styles.ifaceIdInput} /> <SelectPicker data={protocolOptions} value={iface.protocol} onChange={(v) => setInterface(ci, ii, "protocol", v as string)} cleanable={false} className={styles.selectSm} placeholder="Protocol" /> <Form.Control name={`port-${ci}-${ii}`} value={iface.port} onChange={(v) => setInterface(ci, ii, "port", v)} placeholder="Port" className={styles.portInput} type="number" /> <SelectPicker data={visibilityOptions} value={iface.visibilityType} onChange={(v) => setInterface(ci, ii, "visibilityType", v as string)} cleanable={false} className={styles.selectMd} placeholder="Visibility" /> {comp.networkInterfaces.length > 1 && ( <button className={styles.removeBtn} onClick={() => removeInterface(ci, ii)}>✕</button> )} </div> ))} <button className={styles.addBtn} onClick={() => addInterface(ci)}>+ Add interface</button> </div> ))} {error && <p className={styles.error}>{error}</p>} </Form> </Modal.Body> <Modal.Footer> <Button onClick={onClose} appearance="subtle" disabled={submitting}>Cancel</Button> <Button className={buttons.primary} onClick={handleSubmit} disabled={!isValid || submitting} > {submitting ? "Registering…" : "Register application"} </Button> </Modal.Footer> </Modal> ); }; portal-gui/src/app/components/CreateProfileModal/createProfileModal.module.scss 0 → 100644 +150 −0 Original line number Diff line number Diff line .body { display: flex; flex-direction: column; gap: 0.75rem; max-height: 70vh; overflow-y: auto; padding-right: 0.25rem; } .sectionLabel { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--blue-color); margin: 0; } .sectionRow { display: flex; align-items: center; justify-content: space-between; } .row { display: flex; gap: 0.75rem; align-items: flex-end; flex-wrap: wrap; } .grow { flex: 1; min-width: 140px; } .fixed { width: 150px; flex-shrink: 0; } .req { color: #e03a3a; margin-left: 2px; } .opt { color: #aaa; font-size: 0.72rem; font-weight: 400; } .privateBox { background: #f9f9f9; border: 1px solid #e8e8e8; border-radius: 8px; padding: 0.75rem; } .componentBox { background: #f9f9f9; border: 1px solid #e8e8e8; border-radius: 8px; padding: 0.75rem 1rem; display: flex; flex-direction: column; gap: 0.6rem; } .componentHeader { display: flex; gap: 0.75rem; align-items: flex-end; } .ifaceLabel { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #999; margin: 0; } .ifaceRow { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .selectSm { width: 100px; } .selectMd { width: 150px; } .portInput { width: 80px !important; } .ifaceIdInput { width: 130px !important; } .addBtn { background: none; border: 1px dashed #ccc; border-radius: 4px; color: var(--blue-color); font-size: 0.78rem; font-weight: 600; padding: 3px 10px; cursor: pointer; transition: all 0.15s; align-self: flex-start; &:hover { border-color: var(--blue-color); background: #f0f4ff; } } .removeBtn { background: none; border: 1px solid #e0e0e0; border-radius: 4px; color: #aaa; font-size: 0.72rem; font-weight: 600; padding: 3px 8px; cursor: pointer; white-space: nowrap; transition: all 0.15s; flex-shrink: 0; &:hover { background: #ffe5e5; border-color: #e03a3a; color: #e03a3a; } } .error { color: #e03a3a; font-size: 0.85rem; margin-top: 0.5rem; } Loading
portal-gui/src/app/(app)/my-applications/ProfileCard.tsx +3 −3 Original line number Diff line number Diff line Loading @@ -37,8 +37,8 @@ export const ProfileCard = ({ app, onDelete }: Props) => { } }; const externalPorts = app.componentSpec.flatMap((c) => c.networkInterfaces.map((n) => `${n.protocol}:${n.port}`) const externalPorts = (app.componentSpec ?? []).flatMap((c) => (c.networkInterfaces ?? []).map((n) => `${n.protocol}:${n.port}`) ); return ( Loading Loading @@ -92,7 +92,7 @@ export const ProfileCard = ({ app, onDelete }: Props) => { <div className={styles.field}> <dt>Components</dt> <dd>{app.componentSpec.map((c) => c.componentName).join(", ")}</dd> <dd>{(app.componentSpec ?? []).map((c) => c.componentName).join(", ")}</dd> </div> {externalPorts.length > 0 && ( Loading
portal-gui/src/app/(app)/my-applications/page.tsx +13 −3 Original line number Diff line number Diff line Loading @@ -8,12 +8,14 @@ import { IApplicationProfile, MyApplicationsTabKey } from "../../utils/interface import { tabs } from "../../utils/constants"; import { deployments } from "../../utils/tableHelpers"; import { DeploymentCard } from "./DeploymentCard"; import { CreateProfileModal } from "../../components/CreateProfileModal/CreateProfileModal"; const page = () => { const [activeTab, setActiveTab] = useState<MyApplicationsTabKey>("profile"); const [apps, setApps] = useState<IApplicationProfile[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [createOpen, setCreateOpen] = useState(false); useEffect(() => { if (activeTab !== "profile") return; Loading @@ -38,7 +40,7 @@ const page = () => { <div className={styles.box}> <div className={styles.btnBox}> {activeTab === "profile" && ( <Button className={buttons.primary}>+ Create New</Button> <Button className={buttons.primary} onClick={() => setCreateOpen(true)}>+ Create New</Button> )} </div> Loading @@ -51,9 +53,9 @@ const page = () => { <p className={styles.status}>No applications registered yet.</p> )} <div className={styles.cards}> {apps.map((app) => ( {apps.map((app, i) => ( <ProfileCard key={app.appId} key={app.appId ?? i} app={app} onDelete={(id) => setApps((prev) => prev.filter((a) => a.appId !== id))} /> Loading @@ -71,6 +73,14 @@ const page = () => { </div> )} </div> <CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} onCreated={(app) => { setApps((prev) => [...prev, app]); setCreateOpen(false); }} /> </div> ); }; Loading
portal-gui/src/app/api/apps/route.ts +28 −1 Original line number Diff line number Diff line import { NextResponse } from "next/server"; import { oeg } from "@/app/utils/constants"; export async function GET() { export async function POST(req: Request) { try { const body = await req.json(); const res = await fetch(`${oeg.baseUrl}/apps`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), cache: "no-store", }); if (!res.ok) { const err = await res.json().catch(() => ({})); return NextResponse.json( { error: err.message ?? `OEG returned ${res.status}` }, { status: res.status } ); } const data = await res.json(); return NextResponse.json(data, { status: 201 }); } catch { return NextResponse.json( { error: "Could not reach OEG" }, { status: 502 } ); } } export async function GET() { try { const res = await fetch(`${oeg.baseUrl}/apps`, { cache: "no-store", }); Loading
portal-gui/src/app/components/CreateProfileModal/CreateProfileModal.tsx 0 → 100644 +357 −0 Original line number Diff line number Diff line "use client"; import { useState } from "react"; import { Modal, Button, Form, SelectPicker, Divider, Toggle } from "rsuite"; import { IApplicationProfile } from "@/app/utils/interfaces"; import styles from "./createProfileModal.module.scss"; import buttons from "@/app/styles/buttons.module.scss"; type InterfaceForm = { interfaceId: string; protocol: "TCP" | "UDP" | "ANY"; port: string; visibilityType: "VISIBILITY_EXTERNAL" | "VISIBILITY_INTERNAL"; }; type ComponentForm = { componentName: string; networkInterfaces: InterfaceForm[]; }; type FormState = { name: string; version: string; packageType: "HELM" | "CONTAINER" appProvider: string; repoType: "PUBLICREPO" | "PRIVATEREPO"; imagePath: string; userName: string; credentials: string; authType: "DOCKER" | "HTTP_BASIC" | "HTTP_BEARER" | "NONE"; componentSpec: ComponentForm[]; }; const defaultInterface = (): InterfaceForm => ({ interfaceId: "", protocol: "TCP", port: "80", visibilityType: "VISIBILITY_EXTERNAL", }); const defaultComponent = (): ComponentForm => ({ componentName: "", networkInterfaces: [defaultInterface()], }); const initialForm = (): FormState => ({ name: "", version: "", packageType: "HELM", appProvider: "", repoType: "PUBLICREPO", imagePath: "", userName: "", credentials: "", authType: "NONE", componentSpec: [defaultComponent()], }); const packageTypeOptions = ["HELM", "CONTAINER", "QCOW2", "OVA"].map((v) => ({ label: v, value: v })); const repoTypeOptions = ["PUBLICREPO", "PRIVATEREPO"].map((v) => ({ label: v, value: v })); const protocolOptions = ["TCP", "UDP", "ANY"].map((v) => ({ label: v, value: v })); const visibilityOptions = [ { label: "External", value: "VISIBILITY_EXTERNAL" }, { label: "Internal", value: "VISIBILITY_INTERNAL" }, ]; const authTypeOptions = ["NONE", "DOCKER", "HTTP_BASIC", "HTTP_BEARER"].map((v) => ({ label: v, value: v })); type Props = { open: boolean; onClose: () => void; onCreated: (app: IApplicationProfile) => void; }; export const CreateProfileModal = ({ open, onClose, onCreated }: Props) => { const [form, setForm] = useState<FormState>(initialForm); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState<string | null>(null); const set = <K extends keyof FormState>(key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value })); // ── Component helpers ── const setComponent = (i: number, key: keyof ComponentForm, value: string) => setForm((prev) => { const spec = [...prev.componentSpec]; spec[i] = { ...spec[i], [key]: value }; return { ...prev, componentSpec: spec }; }); const setInterface = ( ci: number, ii: number, key: keyof InterfaceForm, value: string ) => setForm((prev) => { const spec = [...prev.componentSpec]; const ifaces = [...spec[ci].networkInterfaces]; ifaces[ii] = { ...ifaces[ii], [key]: value }; spec[ci] = { ...spec[ci], networkInterfaces: ifaces }; return { ...prev, componentSpec: spec }; }); const addInterface = (ci: number) => setForm((prev) => { const spec = [...prev.componentSpec]; spec[ci] = { ...spec[ci], networkInterfaces: [...spec[ci].networkInterfaces, defaultInterface()] }; return { ...prev, componentSpec: spec }; }); const removeInterface = (ci: number, ii: number) => setForm((prev) => { const spec = [...prev.componentSpec]; spec[ci] = { ...spec[ci], networkInterfaces: spec[ci].networkInterfaces.filter((_, idx) => idx !== ii) }; return { ...prev, componentSpec: spec }; }); const addComponent = () => setForm((prev) => ({ ...prev, componentSpec: [...prev.componentSpec, defaultComponent()] })); const removeComponent = (i: number) => setForm((prev) => ({ ...prev, componentSpec: prev.componentSpec.filter((_, idx) => idx !== i) })); const handleSubmit = async () => { setError(null); setSubmitting(true); const body: Record<string, unknown> = { name: form.name, version: form.version, packageType: form.packageType, appRepo: { type: form.repoType, imagePath: form.imagePath, ...(form.repoType === "PRIVATEREPO" && { ...(form.userName && { userName: form.userName }), ...(form.credentials && { credentials: form.credentials }), ...(form.authType !== "NONE" && { authType: form.authType }), }), }, componentSpec: form.componentSpec.map((c) => ({ componentName: c.componentName, networkInterfaces: c.networkInterfaces.map((n) => ({ interfaceId: n.interfaceId, protocol: n.protocol, port: parseInt(n.port, 10), visibilityType: n.visibilityType, })), })), ...(form.appProvider && { appProvider: form.appProvider }), }; try { const res = await fetch("/api/apps", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (res.ok) { const created: IApplicationProfile = await res.json(); onCreated(created); setForm(initialForm()); onClose(); } else { const data = await res.json().catch(() => ({})); setError(data.error ?? "Failed to create application."); } } catch { setError("Could not reach the server."); } finally { setSubmitting(false); } }; const isValid = form.name && form.version && form.imagePath && form.componentSpec.length > 0 && form.componentSpec.every( (c) => c.componentName && c.networkInterfaces.length > 0 && c.networkInterfaces.every((n) => n.interfaceId && n.port) ); return ( <Modal open={open} onClose={onClose} size="md"> <Modal.Header> <Modal.Title>Register New Application</Modal.Title> </Modal.Header> <Modal.Body className={styles.body}> <Form fluid> <p className={styles.sectionLabel}>Basic info</p> <div className={styles.row}> <Form.Group className={styles.grow}> <Form.ControlLabel>App name <span className={styles.req}>*</span></Form.ControlLabel> <Form.Control name="name" value={form.name} onChange={(v) => set("name", v)} placeholder="my-app" /> </Form.Group> <Form.Group className={styles.fixed}> <Form.ControlLabel>Version <span className={styles.req}>*</span></Form.ControlLabel> <Form.Control name="version" value={form.version} onChange={(v) => set("version", v)} placeholder="1.0.0" /> </Form.Group> <Form.Group className={styles.fixed}> <Form.ControlLabel>Package type <span className={styles.req}>*</span></Form.ControlLabel> <SelectPicker data={packageTypeOptions} value={form.packageType} onChange={(v) => set("packageType", v as FormState["packageType"])} cleanable={false} block /> </Form.Group> </div> <Form.Group> <Form.ControlLabel>App provider <span className={styles.opt}>(optional)</span></Form.ControlLabel> <Form.Control name="appProvider" value={form.appProvider} onChange={(v) => set("appProvider", v)} placeholder="e.g. nginx_inc" /> </Form.Group> <Divider /> <p className={styles.sectionLabel}>Repository</p> <div className={styles.row}> <Form.Group className={styles.fixed}> <Form.ControlLabel>Type <span className={styles.req}>*</span></Form.ControlLabel> <SelectPicker data={repoTypeOptions} value={form.repoType} onChange={(v) => set("repoType", v as FormState["repoType"])} cleanable={false} block /> </Form.Group> <Form.Group className={styles.grow}> <Form.ControlLabel>Image path <span className={styles.req}>*</span></Form.ControlLabel> <Form.Control name="imagePath" value={form.imagePath} onChange={(v) => set("imagePath", v)} placeholder="https://..." /> </Form.Group> </div> {/* Private repo extras */} {form.repoType === "PRIVATEREPO" && ( <div className={styles.privateBox}> <div className={styles.row}> <Form.Group className={styles.grow}> <Form.ControlLabel>Username <span className={styles.opt}>(optional)</span></Form.ControlLabel> <Form.Control name="userName" value={form.userName} onChange={(v) => set("userName", v)} /> </Form.Group> <Form.Group className={styles.grow}> <Form.ControlLabel>Credentials / token <span className={styles.opt}>(optional)</span></Form.ControlLabel> <Form.Control name="credentials" value={form.credentials} onChange={(v) => set("credentials", v)} type="password" /> </Form.Group> <Form.Group className={styles.fixed}> <Form.ControlLabel>Auth type</Form.ControlLabel> <SelectPicker data={authTypeOptions} value={form.authType} onChange={(v) => set("authType", v as FormState["authType"])} cleanable={false} block /> </Form.Group> </div> </div> )} <Divider /> <div className={styles.sectionRow}> <p className={styles.sectionLabel}>Components <span className={styles.req}>*</span></p> <button className={styles.addBtn} onClick={addComponent}>+ Add component</button> </div> {form.componentSpec.map((comp, ci) => ( <div key={ci} className={styles.componentBox}> <div className={styles.componentHeader}> <Form.Group className={styles.grow}> <Form.ControlLabel>Component name <span className={styles.req}>*</span></Form.ControlLabel> <Form.Control name={`comp-${ci}`} value={comp.componentName} onChange={(v) => setComponent(ci, "componentName", v)} placeholder="e.g. web-server" /> </Form.Group> {form.componentSpec.length > 1 && ( <button className={styles.removeBtn} onClick={() => removeComponent(ci)}>Remove</button> )} </div> <p className={styles.ifaceLabel}>Network interfaces</p> {comp.networkInterfaces.map((iface, ii) => ( <div key={ii} className={styles.ifaceRow}> <Form.Control name={`iface-id-${ci}-${ii}`} value={iface.interfaceId} onChange={(v) => setInterface(ci, ii, "interfaceId", v)} placeholder="Interface ID *" className={styles.ifaceIdInput} /> <SelectPicker data={protocolOptions} value={iface.protocol} onChange={(v) => setInterface(ci, ii, "protocol", v as string)} cleanable={false} className={styles.selectSm} placeholder="Protocol" /> <Form.Control name={`port-${ci}-${ii}`} value={iface.port} onChange={(v) => setInterface(ci, ii, "port", v)} placeholder="Port" className={styles.portInput} type="number" /> <SelectPicker data={visibilityOptions} value={iface.visibilityType} onChange={(v) => setInterface(ci, ii, "visibilityType", v as string)} cleanable={false} className={styles.selectMd} placeholder="Visibility" /> {comp.networkInterfaces.length > 1 && ( <button className={styles.removeBtn} onClick={() => removeInterface(ci, ii)}>✕</button> )} </div> ))} <button className={styles.addBtn} onClick={() => addInterface(ci)}>+ Add interface</button> </div> ))} {error && <p className={styles.error}>{error}</p>} </Form> </Modal.Body> <Modal.Footer> <Button onClick={onClose} appearance="subtle" disabled={submitting}>Cancel</Button> <Button className={buttons.primary} onClick={handleSubmit} disabled={!isValid || submitting} > {submitting ? "Registering…" : "Register application"} </Button> </Modal.Footer> </Modal> ); };
portal-gui/src/app/components/CreateProfileModal/createProfileModal.module.scss 0 → 100644 +150 −0 Original line number Diff line number Diff line .body { display: flex; flex-direction: column; gap: 0.75rem; max-height: 70vh; overflow-y: auto; padding-right: 0.25rem; } .sectionLabel { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--blue-color); margin: 0; } .sectionRow { display: flex; align-items: center; justify-content: space-between; } .row { display: flex; gap: 0.75rem; align-items: flex-end; flex-wrap: wrap; } .grow { flex: 1; min-width: 140px; } .fixed { width: 150px; flex-shrink: 0; } .req { color: #e03a3a; margin-left: 2px; } .opt { color: #aaa; font-size: 0.72rem; font-weight: 400; } .privateBox { background: #f9f9f9; border: 1px solid #e8e8e8; border-radius: 8px; padding: 0.75rem; } .componentBox { background: #f9f9f9; border: 1px solid #e8e8e8; border-radius: 8px; padding: 0.75rem 1rem; display: flex; flex-direction: column; gap: 0.6rem; } .componentHeader { display: flex; gap: 0.75rem; align-items: flex-end; } .ifaceLabel { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #999; margin: 0; } .ifaceRow { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .selectSm { width: 100px; } .selectMd { width: 150px; } .portInput { width: 80px !important; } .ifaceIdInput { width: 130px !important; } .addBtn { background: none; border: 1px dashed #ccc; border-radius: 4px; color: var(--blue-color); font-size: 0.78rem; font-weight: 600; padding: 3px 10px; cursor: pointer; transition: all 0.15s; align-self: flex-start; &:hover { border-color: var(--blue-color); background: #f0f4ff; } } .removeBtn { background: none; border: 1px solid #e0e0e0; border-radius: 4px; color: #aaa; font-size: 0.72rem; font-weight: 600; padding: 3px 8px; cursor: pointer; white-space: nowrap; transition: all 0.15s; flex-shrink: 0; &:hover { background: #ffe5e5; border-color: #e03a3a; color: #e03a3a; } } .error { color: #e03a3a; font-size: 0.85rem; margin-top: 0.5rem; }