Commit 24068a6c authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement create application modal

parent bfc0ada3
Loading
Loading
Loading
Loading
+3 −3
Original line number Diff line number Diff line
@@ -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 (
@@ -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 && (
+13 −3
Original line number Diff line number Diff line
@@ -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;
@@ -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>

@@ -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))}
                />
@@ -71,6 +73,14 @@ const page = () => {
          </div>
        )}
      </div>
      <CreateProfileModal
        open={createOpen}
        onClose={() => setCreateOpen(false)}
        onCreated={(app) => {
          setApps((prev) => [...prev, app]);
          setCreateOpen(false);
        }}
      />
    </div>
  );
};
+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",
    });

+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>
  );
};
+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