diff --git a/control-plane/src/main/java/io/reshapr/ctrl/model/User.java b/control-plane/src/main/java/io/reshapr/ctrl/model/User.java index 74a74ff..3bdbcba 100644 --- a/control-plane/src/main/java/io/reshapr/ctrl/model/User.java +++ b/control-plane/src/main/java/io/reshapr/ctrl/model/User.java @@ -67,4 +67,17 @@ public String getUserId() { public boolean verifyPassword(String challenged) { return password != null && BcryptUtil.matches(challenged, password); } + + /** + * Ensure the user has a default organization when they belong to at least one. + * Called whenever a membership is added or reassigned. + */ + public void ensureDefaultOrganization() { + if (defaultOrganization != null) { + return; + } + if (organizations != null && !organizations.isEmpty()) { + defaultOrganization = organizations.getFirst(); + } + } } diff --git a/control-plane/src/main/java/io/reshapr/ctrl/rest/admin/OrganizationResource.java b/control-plane/src/main/java/io/reshapr/ctrl/rest/admin/OrganizationResource.java index 6749dde..51a333e 100644 --- a/control-plane/src/main/java/io/reshapr/ctrl/rest/admin/OrganizationResource.java +++ b/control-plane/src/main/java/io/reshapr/ctrl/rest/admin/OrganizationResource.java @@ -126,8 +126,9 @@ public Response changeOwner(@PathParam("organizationName") String organizationNa // Assign organization to user. if (!user.organizations.contains(organization)) { user.organizations.add(organization); - userRepository.persistAndFlush(user); } + user.ensureDefaultOrganization(); + userRepository.persistAndFlush(user); // Change owner on organization side. organization.owner = user; organizationRepository.persistAndFlush(organization); diff --git a/control-plane/src/main/java/io/reshapr/ctrl/rest/admin/UserResource.java b/control-plane/src/main/java/io/reshapr/ctrl/rest/admin/UserResource.java index eea9d47..f1960de 100644 --- a/control-plane/src/main/java/io/reshapr/ctrl/rest/admin/UserResource.java +++ b/control-plane/src/main/java/io/reshapr/ctrl/rest/admin/UserResource.java @@ -167,9 +167,7 @@ public Response updateOrganizationOwner(@PathParam("username") String username, if (user.organizations.stream().noneMatch(o -> o.name.equals(organizationName))) { user.organizations.add(organization); } - if (user.defaultOrganization == null) { - user.defaultOrganization = organization; - } + user.ensureDefaultOrganization(); userRepository.persistAndFlush(user); return Response.ok(new OrganizationDTO(organizationName, organization.description, organization.icon)).build(); @@ -191,6 +189,7 @@ public Response assignMembership(@PathParam("username") String username, List(); } user.organizations.add(organization); - if (user.defaultOrganization == null) { - user.defaultOrganization = organization; - } + user.ensureDefaultOrganization(); userRepository.persistAndFlush(user); return organization; } diff --git a/dev/start-keycloak-docker.sh b/dev/start-keycloak-docker.sh index 091ed94..269307b 100755 --- a/dev/start-keycloak-docker.sh +++ b/dev/start-keycloak-docker.sh @@ -1,3 +1,58 @@ -docker run -it --rm -v $(pwd):/opt/keycloak/data/import -p 8888:8080 \ - -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \ - quay.io/keycloak/keycloak:26.3.0 start-dev --hostname http://localhost:8888 --import-realm +#!/usr/bin/env bash +# Start Keycloak for local Reshapr OIDC dev (realm 3rdparty on port 8888). +# Single terminal: starts container, fixes master sslRequired, then follows logs. +set -euo pipefail + +CONTAINER_NAME="${RESHAPR_KEYCLOAK_CONTAINER:-reshapr-keycloak-dev}" +KEYCLOAK_IMAGE="${KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:26.3.0}" +HOST_PORT="${KEYCLOAK_HOST_PORT:-8888}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + +echo "Starting Keycloak (${CONTAINER_NAME}) on http://localhost:${HOST_PORT} ..." +docker run -d --rm --name "${CONTAINER_NAME}" \ + -v "${SCRIPT_DIR}:/opt/keycloak/data/import" \ + -p "${HOST_PORT}:8080" \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + -e KC_HOSTNAME=localhost \ + -e KC_HOSTNAME_STRICT=false \ + -e KC_HTTP_ENABLED=true \ + "${KEYCLOAK_IMAGE}" \ + start-dev --hostname "http://localhost:${HOST_PORT}" --import-realm + +echo "Waiting for Keycloak to be ready ..." +READY=0 +for i in $(seq 1 90); do + if curl -sf "http://localhost:${HOST_PORT}/realms/3rdparty" >/dev/null 2>&1; then + READY=1 + break + fi + printf '.' + sleep 2 +done +echo "" + +if [ "${READY}" -ne 1 ]; then + echo "Keycloak did not become ready in time. Logs:" >&2 + docker logs "${CONTAINER_NAME}" 2>&1 | tail -50 >&2 + exit 1 +fi + +echo "Disabling SSL requirement on master realm (local dev only) ..." +docker exec "${CONTAINER_NAME}" /opt/keycloak/bin/kcadm.sh config credentials \ + --server "http://localhost:8080" --realm master --user admin --password admin + +docker exec "${CONTAINER_NAME}" /opt/keycloak/bin/kcadm.sh update realms/master -s sslRequired=NONE + +echo "" +echo "Keycloak ready:" +echo " Admin console: http://localhost:${HOST_PORT}/admin (admin / admin)" +echo " Realm OIDC: 3rdparty (client reshapr-ctrl, user laurent / laurent)" +echo " Stop: docker stop ${CONTAINER_NAME}" +echo "" +echo "Following logs (Ctrl+C stops tail only; container keeps running until docker stop):" +docker logs -f "${CONTAINER_NAME}" \ No newline at end of file diff --git a/web-ui/src/lib/api/client.ts b/web-ui/src/lib/api/client.ts new file mode 100644 index 0000000..81017d5 --- /dev/null +++ b/web-ui/src/lib/api/client.ts @@ -0,0 +1,173 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiError } from './errors.js'; + +export { ApiError } from './errors.js'; + +async function parseErrorBody(res: Response): Promise { + const t = await res.text(); + return t || res.statusText; +} + +/** + * REST client for control-plane v1 APIs via the SvelteKit BFF proxy. + * Authentication is handled server-side (httpOnly cookie); no Bearer header in the browser. + */ +export function apiClient() { + const json = async (path: string, init?: RequestInit): Promise => { + const res = await fetch(path, init); + if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status); + if (res.status === 204) return undefined as T; + const ct = res.headers.get('content-type'); + if (ct?.includes('application/json')) return res.json() as Promise; + return (await res.text()) as T; + }; + + const empty = async (path: string, init?: RequestInit) => { + const res = await fetch(path, init); + if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status); + }; + + return { + listServices: () => json('/api/v1/services?page=0&size=500'), + listServicesPage: (page: number, size: number) => + json(`/api/v1/services?page=${page}&size=${size}`), + getService: (id: string) => json(`/api/v1/services/${id}`), + deleteService: (id: string) => empty(`/api/v1/services/${id}`, { method: 'DELETE' }), + + listArtifactsByService: (serviceId: string) => + json(`/api/v1/artifacts/service/${serviceId}`), + + importArtifactFile: async (file: File, extra?: Record) => { + const fd = new FormData(); + fd.append('file', file); + fd.append('mainArtifact', 'true'); + if (extra?.serviceName) fd.append('serviceName', extra.serviceName); + if (extra?.serviceVersion) fd.append('serviceVersion', extra.serviceVersion); + const res = await fetch('/api/v1/artifacts', { method: 'POST', body: fd }); + if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status); + return res.json(); + }, + + importArtifactUrl: async (params: URLSearchParams) => { + const res = await fetch('/api/v1/artifacts', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }); + if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status); + return res.json(); + }, + + attachArtifactFile: async (file: File) => { + const fd = new FormData(); + fd.append('file', file); + const res = await fetch('/api/v1/artifacts/attach', { method: 'POST', body: fd }); + if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status); + return res.json(); + }, + + attachArtifactUrl: async (url: string, secretName?: string) => { + const p = new URLSearchParams(); + p.set('url', url); + if (secretName) p.set('secretName', secretName); + const res = await fetch('/api/v1/artifacts/attach', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: p.toString() + }); + if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status); + return res.json(); + }, + + listConfigurationPlans: () => json('/api/v1/configurationPlans'), + getConfigurationPlan: (id: string) => json(`/api/v1/configurationPlans/${id}`), + createConfigurationPlan: (body: unknown) => + json('/api/v1/configurationPlans', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }), + updateConfigurationPlan: (id: string, body: unknown) => + json(`/api/v1/configurationPlans/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }), + deleteConfigurationPlan: (id: string) => + empty(`/api/v1/configurationPlans/${id}`, { method: 'DELETE' }), + renewApiKey: (id: string) => + json(`/api/v1/configurationPlans/${id}/renewApiKey`, { method: 'PUT' }), + + listExpositionsAll: () => json('/api/v1/expositions'), + listExpositionsActive: () => json('/api/v1/expositions/active'), + getExposition: (id: string) => json(`/api/v1/expositions/${id}`), + getActiveExposition: (id: string) => json(`/api/v1/expositions/active/${id}`), + getActiveExpositionOrNull: async (id: string): Promise => { + const res = await fetch(`/api/v1/expositions/active/${id}`); + if (res.status === 404) return null; + if (!res.ok) throw new ApiError(await parseErrorBody(res), res.status); + return res.json() as Promise; + }, + createExposition: (body: { configurationPlanId: string; gatewayGroupId: string }) => + json('/api/v1/expositions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }), + deleteExposition: (id: string) => empty(`/api/v1/expositions/${id}`, { method: 'DELETE' }), + + listSecretRefs: () => json('/api/v1/secrets/refs?page=0&size=500'), + listSecrets: (page = 0, size = 500) => + json(`/api/v1/secrets?page=${page}&size=${size}`), + getSecret: (id: string) => json(`/api/v1/secrets/${id}`), + createSecret: (body: unknown) => + json('/api/v1/secrets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }), + updateSecret: (id: string, body: unknown) => + json(`/api/v1/secrets/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }), + deleteSecret: (id: string) => empty(`/api/v1/secrets/${id}`, { method: 'DELETE' }), + + listGatewayGroups: () => json('/api/v1/gatewayGroups'), + createGatewayGroup: (body: unknown) => + json('/api/v1/gatewayGroups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }), + deleteGatewayGroup: (id: string) => empty(`/api/v1/gatewayGroups/${id}`, { method: 'DELETE' }), + + getQuotas: () => json('/api/v1/quotas'), + + listApiTokens: () => json('/api/v1/tokens/apiTokens'), + createApiToken: (body: unknown) => + json('/api/v1/tokens/apiTokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }), + deleteApiToken: (tokenId: string) => + empty(`/api/v1/tokens/apiTokens/${tokenId}`, { method: 'DELETE' }) + }; +} diff --git a/web-ui/src/lib/api/errors.ts b/web-ui/src/lib/api/errors.ts new file mode 100644 index 0000000..9904e6f --- /dev/null +++ b/web-ui/src/lib/api/errors.ts @@ -0,0 +1,27 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class ApiError extends Error { + readonly status: number; + readonly body: string | undefined; + + constructor(message: string, status: number, body?: string) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.body = body; + } +} diff --git a/web-ui/src/lib/components/ApiErrorAlert.svelte b/web-ui/src/lib/components/ApiErrorAlert.svelte new file mode 100644 index 0000000..80e1998 --- /dev/null +++ b/web-ui/src/lib/components/ApiErrorAlert.svelte @@ -0,0 +1,26 @@ + + + + + + Error + {message} + diff --git a/web-ui/src/lib/components/JsonBlock.svelte b/web-ui/src/lib/components/JsonBlock.svelte new file mode 100644 index 0000000..b1d3584 --- /dev/null +++ b/web-ui/src/lib/components/JsonBlock.svelte @@ -0,0 +1,25 @@ + + + + +
{text}
diff --git a/web-ui/src/lib/components/PageHeader.svelte b/web-ui/src/lib/components/PageHeader.svelte new file mode 100644 index 0000000..54a0b7b --- /dev/null +++ b/web-ui/src/lib/components/PageHeader.svelte @@ -0,0 +1,36 @@ + + + + +
+

{title}

+ {#if actions} +
+ {@render actions()} +
+ {/if} +
diff --git a/web-ui/src/lib/components/ScrollableCode.svelte b/web-ui/src/lib/components/ScrollableCode.svelte new file mode 100644 index 0000000..d05a1da --- /dev/null +++ b/web-ui/src/lib/components/ScrollableCode.svelte @@ -0,0 +1,42 @@ + + + + +
+ {text} +
diff --git a/web-ui/src/lib/components/ui/alert/alert-action.svelte b/web-ui/src/lib/components/ui/alert/alert-action.svelte new file mode 100644 index 0000000..1877d38 --- /dev/null +++ b/web-ui/src/lib/components/ui/alert/alert-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/web-ui/src/lib/components/ui/alert/alert-description.svelte b/web-ui/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..7ee6039 --- /dev/null +++ b/web-ui/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/web-ui/src/lib/components/ui/alert/alert-title.svelte b/web-ui/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..3e339a3 --- /dev/null +++ b/web-ui/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,23 @@ + + +
svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3", + className + )} + {...restProps} +> + {@render children?.()} +
diff --git a/web-ui/src/lib/components/ui/alert/alert.svelte b/web-ui/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..abf7487 --- /dev/null +++ b/web-ui/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,43 @@ + + + + + diff --git a/web-ui/src/lib/components/ui/alert/index.ts b/web-ui/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..071b113 --- /dev/null +++ b/web-ui/src/lib/components/ui/alert/index.ts @@ -0,0 +1,17 @@ +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +import Action from "./alert-action.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + Action, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, + Action as AlertAction, +}; diff --git a/web-ui/src/lib/components/ui/card/index.ts b/web-ui/src/lib/components/ui/card/index.ts index ede92b4..3868fe9 100644 --- a/web-ui/src/lib/components/ui/card/index.ts +++ b/web-ui/src/lib/components/ui/card/index.ts @@ -1,6 +1,25 @@ -export { default as Card } from './card.svelte'; -export { default as CardHeader } from './card-header.svelte'; -export { default as CardTitle } from './card-title.svelte'; -export { default as CardDescription } from './card-description.svelte'; -export { default as CardContent } from './card-content.svelte'; +import Root from './card.svelte'; +import Content from './card-content.svelte'; +import Description from './card-description.svelte'; +import Footer from './card-footer.svelte'; +import Header from './card-header.svelte'; +import Title from './card-title.svelte'; +import Action from './card-action.svelte'; +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction +}; diff --git a/web-ui/src/lib/components/ui/collapsible/collapsible-content.svelte b/web-ui/src/lib/components/ui/collapsible/collapsible-content.svelte new file mode 100644 index 0000000..bdabb55 --- /dev/null +++ b/web-ui/src/lib/components/ui/collapsible/collapsible-content.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web-ui/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/web-ui/src/lib/components/ui/collapsible/collapsible-trigger.svelte new file mode 100644 index 0000000..ece7ad6 --- /dev/null +++ b/web-ui/src/lib/components/ui/collapsible/collapsible-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web-ui/src/lib/components/ui/collapsible/collapsible.svelte b/web-ui/src/lib/components/ui/collapsible/collapsible.svelte new file mode 100644 index 0000000..39cdd4e --- /dev/null +++ b/web-ui/src/lib/components/ui/collapsible/collapsible.svelte @@ -0,0 +1,11 @@ + + + diff --git a/web-ui/src/lib/components/ui/collapsible/index.ts b/web-ui/src/lib/components/ui/collapsible/index.ts new file mode 100644 index 0000000..169b479 --- /dev/null +++ b/web-ui/src/lib/components/ui/collapsible/index.ts @@ -0,0 +1,13 @@ +import Root from "./collapsible.svelte"; +import Trigger from "./collapsible-trigger.svelte"; +import Content from "./collapsible-content.svelte"; + +export { + Root, + Content, + Trigger, + // + Root as Collapsible, + Content as CollapsibleContent, + Trigger as CollapsibleTrigger, +}; diff --git a/web-ui/src/lib/components/ui/select/index.ts b/web-ui/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..4dec358 --- /dev/null +++ b/web-ui/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import Root from "./select.svelte"; +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Portal from "./select-portal.svelte"; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal, +}; diff --git a/web-ui/src/lib/components/ui/select/select-content.svelte b/web-ui/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..bb1e2ac --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,45 @@ + + + + + + + {@render children?.()} + + + + diff --git a/web-ui/src/lib/components/ui/select/select-group-heading.svelte b/web-ui/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/web-ui/src/lib/components/ui/select/select-group.svelte b/web-ui/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..f666cb2 --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,17 @@ + + + diff --git a/web-ui/src/lib/components/ui/select/select-item.svelte b/web-ui/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..32fd5ce --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/web-ui/src/lib/components/ui/select/select-label.svelte b/web-ui/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..69bcfdf --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/web-ui/src/lib/components/ui/select/select-portal.svelte b/web-ui/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 0000000..424bcdd --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web-ui/src/lib/components/ui/select/select-scroll-down-button.svelte b/web-ui/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..94f41cd --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/web-ui/src/lib/components/ui/select/select-scroll-up-button.svelte b/web-ui/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..035ea09 --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/web-ui/src/lib/components/ui/select/select-separator.svelte b/web-ui/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..3b24bab --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/web-ui/src/lib/components/ui/select/select-trigger.svelte b/web-ui/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..03d06d0 --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/web-ui/src/lib/components/ui/select/select.svelte b/web-ui/src/lib/components/ui/select/select.svelte new file mode 100644 index 0000000..05eb663 --- /dev/null +++ b/web-ui/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/web-ui/src/lib/components/ui/textarea/index.ts b/web-ui/src/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..ace797a --- /dev/null +++ b/web-ui/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,7 @@ +import Root from "./textarea.svelte"; + +export { + Root, + // + Root as Textarea, +}; diff --git a/web-ui/src/lib/components/ui/textarea/textarea.svelte b/web-ui/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..2e779ff --- /dev/null +++ b/web-ui/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,23 @@ + + + diff --git a/web-ui/src/lib/dashboardStats.ts b/web-ui/src/lib/dashboardStats.ts new file mode 100644 index 0000000..b258f75 --- /dev/null +++ b/web-ui/src/lib/dashboardStats.ts @@ -0,0 +1,115 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { apiClient } from '$lib/api/client.js'; +import { + buildGatewayRegisteredDetail, + quotaUsed, + type GatewayRegisteredDetail +} from '$lib/dashboardStatsCompute.js'; + +export type { GatewayRegisteredDetail, GatewayRowDetail } from '$lib/dashboardStatsCompute' +export { + aggregateGatewaysFromActiveExpositions, + buildGatewayRegisteredDetail, + quotaEntry +} from '$lib/dashboardStatsCompute' + +type Api = ReturnType + +export type DashboardStats = { + /** Current tenant (JWT); not a platform-wide org list. */ + organizationId: string | null + serviceCount: number + /** From quotas (`gateway.count` used) when available, else active exposition gateways. */ + gatewayRegisteredCount: number + /** Gateways on active expositions with at least one FQDN (best-effort without heartbeat API). */ + gatewayHealthyCount: number + gatewayGroupsCount: number | null + expositionCount: number | null + /** Not exposed on GET /api/v1/* — always null until upstream adds an endpoint. */ + userCount: null + /** Not exposed on GET /api/v1/* — always null until upstream adds an endpoint. */ + organizationCount: null + gatewayRegisteredDetail: GatewayRegisteredDetail +} + +async function countAllServices(c: Api): Promise<{ count: number; organizationId: string | null }> { + const size = 100 + let page = 0 + let total = 0 + let organizationId: string | null = null + for (;;) { + const batch = await c.listServicesPage(page, size) + if (!Array.isArray(batch) || batch.length === 0) break + if (!organizationId && batch[0] && typeof batch[0] === 'object') { + const id = (batch[0] as Record).organizationId + if (typeof id === 'string' && id) organizationId = id + } + total += batch.length + if (batch.length < size) break + page += 1 + } + return { count: total, organizationId } +} + +function pickOrganizationId(services: unknown[], gatewayGroups: unknown[]): string | null { + for (const raw of services) { + if (raw && typeof raw === 'object') { + const id = (raw as Record).organizationId + if (typeof id === 'string' && id) return id + } + } + for (const raw of gatewayGroups) { + if (raw && typeof raw === 'object') { + const id = (raw as Record).organizationId + if (typeof id === 'string' && id) return id + } + } + return null +} + +/** Dashboard metrics using only existing v1 REST APIs (no control-plane changes). */ +export async function loadDashboardStats(): Promise { + const c = apiClient() + + const [services, activeExpositions, quotas, gatewayGroups] = await Promise.all([ + countAllServices(c), + c.listExpositionsActive(), + c.getQuotas(), + c.listGatewayGroups() + ]) + + const active = Array.isArray(activeExpositions) ? activeExpositions : [] + const gatewayRegisteredDetail = buildGatewayRegisteredDetail(active, quotas) + const fromActive = gatewayRegisteredDetail.fromActiveExpositions + + const orgId = + services.organizationId ?? + pickOrganizationId([], Array.isArray(gatewayGroups) ? gatewayGroups : []) + + return { + organizationId: orgId, + serviceCount: services.count, + gatewayRegisteredCount: gatewayRegisteredDetail.displayedCount, + gatewayHealthyCount: fromActive.healthy, + gatewayGroupsCount: quotaUsed(quotas, 'gateway-group.count'), + expositionCount: quotaUsed(quotas, 'exposition.count'), + userCount: null, + organizationCount: null, + gatewayRegisteredDetail + } +} diff --git a/web-ui/src/lib/dashboardStatsCompute.ts b/web-ui/src/lib/dashboardStatsCompute.ts new file mode 100644 index 0000000..ff49b12 --- /dev/null +++ b/web-ui/src/lib/dashboardStatsCompute.ts @@ -0,0 +1,149 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type GatewayRowDetail = { + key: string + id?: string + name?: string + hasFqdn: boolean + /** Exposition id or name where this gateway appears (active list). */ + onActiveExpositions: string[] +} + +export type GatewayRegisteredDetail = { + displayedCount: number + /** How the card value was chosen. */ + source: 'quota_only' | 'active_expositions_only' | 'max_quota_and_active' + quota: { metric: string; used: number; limit: number; remaining: number } | null + fromActiveExpositions: { + registered: number + healthy: number + gateways: GatewayRowDetail[] + } +} + +type GatewayRow = { id?: string; name?: string; fqdns?: unknown[] } + +function expositionLabel(expo: Record): string { + if (typeof expo.id === 'string' && expo.id) return expo.id + if (typeof expo.name === 'string' && expo.name) return expo.name + return '(exposition)' +} + +/** Dedupe gateways by `id`, else `name`, across GET /api/v1/expositions/active. */ +export function aggregateGatewaysFromActiveExpositions(active: unknown[]): { + registered: number + healthy: number + gateways: GatewayRowDetail[] +} { + const byKey = new Map() + for (const expo of active) { + if (!expo || typeof expo !== 'object') continue + const expoObj = expo as Record + const expoId = expositionLabel(expoObj) + const gws = expoObj.gateways + if (!Array.isArray(gws)) continue + for (const g of gws) { + if (!g || typeof g !== 'object') continue + const row = g as GatewayRow + const key = + typeof row.id === 'string' && row.id + ? row.id + : typeof row.name === 'string' && row.name + ? row.name + : null + if (!key) continue + const fqdns = Array.isArray(row.fqdns) ? row.fqdns : [] + const hasFqdn = fqdns.some((f) => typeof f === 'string' && f.trim().length > 0) + const existing = byKey.get(key) + if (existing) { + existing.hasFqdn = existing.hasFqdn || hasFqdn + if (!existing.onActiveExpositions.includes(expoId)) { + existing.onActiveExpositions.push(expoId) + } + } else { + byKey.set(key, { + key, + id: typeof row.id === 'string' ? row.id : undefined, + name: typeof row.name === 'string' ? row.name : undefined, + hasFqdn, + onActiveExpositions: [expoId] + }) + } + } + } + const gateways = [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)) + let healthy = 0 + for (const g of gateways) { + if (g.hasFqdn) healthy += 1 + } + return { registered: gateways.length, healthy, gateways } +} + +export function quotaEntry( + quotas: unknown, + metric: string +): { used: number; limit: number; remaining: number } | null { + if (!Array.isArray(quotas)) return null + for (const q of quotas) { + if (!q || typeof q !== 'object') continue + const o = q as Record + if (o.metric !== metric) continue + const limit = Number(o.limit) + const remaining = Number(o.remaining) + if (!Number.isFinite(limit) || !Number.isFinite(remaining)) return null + return { used: Math.max(0, limit - remaining), limit, remaining } + } + return null +} + +export function quotaUsed(quotas: unknown, metric: string): number | null { + return quotaEntry(quotas, metric)?.used ?? null +} + +export function buildGatewayRegisteredDetail( + activeExpositions: unknown[], + quotas: unknown +): GatewayRegisteredDetail { + const fromActive = aggregateGatewaysFromActiveExpositions( + Array.isArray(activeExpositions) ? activeExpositions : [] + ) + const quota = quotaEntry(quotas, 'gateway.count') + const fromQuota = quota?.used ?? null + + let displayedCount: number + let source: GatewayRegisteredDetail['source'] + if (fromQuota == null) { + displayedCount = fromActive.registered + source = 'active_expositions_only' + } else { + displayedCount = Math.max(fromQuota, fromActive.registered) + if (displayedCount === fromQuota && fromQuota > fromActive.registered) { + source = 'quota_only' + } else if (displayedCount === fromActive.registered && fromActive.registered > fromQuota) { + source = 'active_expositions_only' + } else { + source = 'max_quota_and_active' + } + } + + return { + displayedCount, + source, + quota: quota ? { metric: 'gateway.count', ...quota } : null, + fromActiveExpositions: fromActive + } +} diff --git a/web-ui/src/lib/format-api-error.ts b/web-ui/src/lib/format-api-error.ts new file mode 100644 index 0000000..62c7c03 --- /dev/null +++ b/web-ui/src/lib/format-api-error.ts @@ -0,0 +1,23 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiError } from '$lib/api/errors.js'; + +export function formatApiError(e: unknown): string { + if (!(e instanceof ApiError)) return String(e); + const body = e.message.length > 1200 ? `${e.message.slice(0, 1200)}…` : e.message; + return `HTTP ${e.status}: ${body}`; +} diff --git a/web-ui/src/lib/mcpCustomTools.ts b/web-ui/src/lib/mcpCustomTools.ts new file mode 100644 index 0000000..914a576 --- /dev/null +++ b/web-ui/src/lib/mcpCustomTools.ts @@ -0,0 +1,369 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Resolves MCP custom tools from an endpoint URL and control-plane REST APIs + * (artifacts `RESHAPR_CUSTOM_TOOLS` and service operations, filtered by exposition). + */ + +export type McpCustomToolsClient = { + listServicesPage: (page: number, size: number) => Promise + listExpositionsActive: () => Promise + listExpositionsAll: () => Promise + getExposition: (id: string) => Promise + listArtifactsByService: (serviceId: string) => Promise + getService: (id: string) => Promise +} + +export type McpCustomToolRow = { + name: string + description: string + inputSchema: Record +} + +export type McpCustomToolsResolution = { + tools: McpCustomToolRow[] + source: 'artifacts_custom_tools' | 'services_operations' + expoId: string + serviceId: string + /** Full `RESHAPR_CUSTOM_TOOLS` artifact YAML when tools come from that artifact. */ + artifactYaml?: string +} + +import { parseMcpUrl } from './mcpUrl' + +export { parseMcpUrl } from './mcpUrl' + +function parseInputSchemaFromYamlBlock(block: string): { type: string; properties: Record } { + const m = block.match(/^ input:\s*\n([\s\S]+)/m) + if (!m) return { type: 'object', properties: {} } + const body = m[1] + const properties: Record> = {} + let cur: string | null = null + for (const line of body.split('\n')) { + const prop = line.match(/^ ([a-zA-Z0-9_]+):\s*$/) + if (prop) { + cur = prop[1] + properties[cur] = {} + continue + } + if (!cur) continue + const typ = line.match(/^ type:\s*(.+)$/) + if (typ) properties[cur].type = typ[1].trim() + const desc = line.match(/^ description:\s*(.+)$/) + if (desc) properties[cur].description = desc[1].trim() + const def = line.match(/^ default:\s*(.+)$/) + if (def) { + const v = def[1].trim() + properties[cur].default = /^\d+$/.test(v) ? Number(v) : v + } + } + return { type: 'object', properties } +} + +type ParsedYamlTool = { + name: string + tool: string + description: string + inputSchema: { type: string; properties: Record } +} + +function parseReshaprCustomToolsYaml(content: string): ParsedYamlTool[] { + if (!content || typeof content !== 'string') return [] + const marker = 'customTools:' + const idx = content.indexOf(marker) + if (idx === -1) return [] + let rest = content.slice(idx + marker.length) + rest = rest.replace(/^\s*\n/, '') + const tools: ParsedYamlTool[] = [] + const keyRe = /^ ([a-zA-Z0-9_]+):\s*$/gm + const keys: { name: string; start: number; endHeader: number }[] = [] + let mm: RegExpExecArray | null + while ((mm = keyRe.exec(rest)) !== null) { + keys.push({ name: mm[1], start: mm.index, endHeader: mm.index + mm[0].length }) + } + for (let i = 0; i < keys.length; i++) { + const block = rest.slice(keys[i].endHeader, i + 1 < keys.length ? keys[i + 1].start : rest.length) + const toolLine = block.match(/^\s{4}tool:\s*(.+)$/m) + const titleLine = block.match(/^\s{4}title:\s*(.+)$/m) + let description = '' + const folded = block.match(/^\s{4}description:\s*>\s*\n([\s\S]*?)(?=^\s{4}(?:input|tool|title):)/m) + if (folded) { + description = folded[1] + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .join(' ') + .trim() + } else { + const inlineD = block.match(/^\s{4}description:\s*(.+)$/m) + if (inlineD) description = inlineD[1].trim() + } + const inputSchema = parseInputSchemaFromYamlBlock(block) + tools.push({ + name: keys[i].name, + tool: toolLine ? toolLine[1].trim() : '', + description: description || (titleLine ? titleLine[1].trim() : ''), + inputSchema, + }) + } + return tools +} + +function includedOperationsList(value: unknown): string[] { + if (value == null) return [] + if (Array.isArray(value)) return value.map(String) + return [] +} + +function filterCustomToolsByIncluded(tools: ParsedYamlTool[], included: string[]): ParsedYamlTool[] { + if (!included.length) return tools + const set = new Set(included) + return tools.filter((t) => t.tool && set.has(t.tool)) +} + +function toolNameFromOperation(operationName: string): string { + let s = String(operationName || '').trim() + if (!s) return 'unnamed_tool' + s = s.replace(/[^a-zA-Z0-9]+/g, '_').replace(/_+/g, '_') + if (/^[0-9]/.test(s)) s = `op_${s}` + return s +} + +function fqdnToHost(fqdn: string): string { + const s = String(fqdn || '').trim() + if (!s) return '' + try { + if (/^https?:\/\//i.test(s)) return new URL(s).host + } catch { + // ignore + } + return s.split('/')[0].trim() +} + +type ExpoListRow = { + id?: string + createdOn?: string + service?: { id?: string } + gateways?: { fqdns?: unknown[] }[] +} + +function expositionMatchesHost(expo: ExpoListRow, serviceId: string, mcpHost: string): boolean { + if (!expo?.service?.id || expo.service.id !== serviceId) return false + const gateways = Array.isArray(expo.gateways) ? expo.gateways : [] + for (const g of gateways) { + const fqdns = Array.isArray(g?.fqdns) ? g.fqdns : [] + for (const fqdn of fqdns) { + if (typeof fqdn === 'string' && fqdnToHost(fqdn) === mcpHost) return true + } + } + return false +} + +function pickNewestExposition(expos: ExpoListRow[]): ExpoListRow | null { + if (!Array.isArray(expos) || expos.length === 0) return null + const sorted = [...expos].sort((a, b) => + String(b.createdOn || '').localeCompare(String(a.createdOn || '')), + ) + return sorted[0] ?? null +} + +async function listAllServices(client: McpCustomToolsClient): Promise { + const size = 100 + let page = 0 + const out: unknown[] = [] + for (;;) { + const batch = await client.listServicesPage(page, size) + if (!Array.isArray(batch) || batch.length === 0) break + out.push(...batch) + if (batch.length < size) break + page += 1 + } + return out +} + +async function resolveExpositionForMcp( + client: McpCustomToolsClient, + serviceId: string, + mcpHost: string, +): Promise { + const active = (await client.listExpositionsActive()) as ExpoListRow[] + const activeList = Array.isArray(active) ? active : [] + const fromActive = pickNewestExposition( + activeList.filter((e) => expositionMatchesHost(e, serviceId, mcpHost)), + ) + if (fromActive?.id) { + return client.getExposition(fromActive.id) + } + const all = (await client.listExpositionsAll()) as ExpoListRow[] + const allList = Array.isArray(all) ? all : [] + const fromAll = pickNewestExposition( + allList.filter((e) => expositionMatchesHost(e, serviceId, mcpHost)), + ) + if (!fromAll?.id) return null + return client.getExposition(fromAll.id) +} + +type ServiceRow = { + id?: string + organizationId?: string + name?: string + version?: string +} + +type ArtifactRow = { + type?: string + content?: string | null +} + +type ExpoDetail = { + id?: string + configurationPlan?: { includedOperations?: unknown } +} + +type ServiceView = { + operations?: { name?: string }[] +} + +async function resolveExpositionForService( + client: McpCustomToolsClient, + serviceId: string, + mcpHost?: string, +): Promise { + if (mcpHost) { + const raw = await resolveExpositionForMcp(client, serviceId, mcpHost) + return raw as ExpoDetail | null + } + const active = (await client.listExpositionsActive()) as ExpoListRow[] + const activeList = Array.isArray(active) ? active : [] + const fromActive = pickNewestExposition( + activeList.filter((e) => e?.service?.id === serviceId), + ) + if (fromActive?.id) { + return (await client.getExposition(fromActive.id)) as ExpoDetail + } + const all = (await client.listExpositionsAll()) as ExpoListRow[] + const allList = Array.isArray(all) ? all : [] + const fromAll = pickNewestExposition(allList.filter((e) => e?.service?.id === serviceId)) + if (!fromAll?.id) return null + return (await client.getExposition(fromAll.id)) as ExpoDetail +} + +export async function resolveMcpCustomToolsForService( + serviceId: string, + client: McpCustomToolsClient, + options?: { exposition?: ExpoDetail | null }, +): Promise { + const view = (await client.getService(serviceId)) as ServiceRow & ServiceView + if (!view?.id) { + throw new Error(`Service not found: ${serviceId}`) + } + const expo = + options && 'exposition' in options + ? (options.exposition ?? null) + : await resolveExpositionForService(client, serviceId) + if (!expo?.id) { + const artifacts = (await client.listArtifactsByService(serviceId)) as ArtifactRow[] + const artifactList = Array.isArray(artifacts) ? artifacts : [] + const yamlArtifact = artifactList.find((a) => a && a.type === 'RESHAPR_CUSTOM_TOOLS' && a.content) + const customParsed = parseReshaprCustomToolsYaml(yamlArtifact?.content || '') + if (customParsed.length > 0) { + const customOut: McpCustomToolRow[] = customParsed.map((t) => ({ + name: t.name, + description: t.description || '', + inputSchema: (t.inputSchema || { type: 'object', properties: {} }) as Record, + })) + return { + tools: customOut, + source: 'artifacts_custom_tools', + expoId: '', + serviceId, + artifactYaml: yamlArtifact?.content ?? undefined, + } + } + const ops = Array.isArray(view?.operations) ? view.operations : [] + const restOut: McpCustomToolRow[] = ops.map((op) => ({ + name: toolNameFromOperation(String(op.name)), + description: String(op.name), + inputSchema: { type: 'object' }, + })) + return { + tools: restOut, + source: 'services_operations', + expoId: '', + serviceId, + } + } + const included = includedOperationsList(expo.configurationPlan?.includedOperations) + const artifacts = (await client.listArtifactsByService(serviceId)) as ArtifactRow[] + const artifactList = Array.isArray(artifacts) ? artifacts : [] + const yamlArtifact = artifactList.find((a) => a && a.type === 'RESHAPR_CUSTOM_TOOLS' && a.content) + const customParsed = parseReshaprCustomToolsYaml(yamlArtifact?.content || '') + const customFiltered = filterCustomToolsByIncluded(customParsed, included) + const customOut: McpCustomToolRow[] = customFiltered.map((t) => ({ + name: t.name, + description: t.description || '', + inputSchema: (t.inputSchema || { type: 'object', properties: {} }) as Record, + })) + if (customOut.length > 0) { + return { + tools: customOut, + source: 'artifacts_custom_tools', + expoId: expo.id, + serviceId, + artifactYaml: yamlArtifact?.content ?? undefined, + } + } + const ops = Array.isArray(view?.operations) ? view.operations : [] + const includedSet = new Set(included) + const restOps = included.length ? ops.filter((o) => o && includedSet.has(String(o.name))) : ops + const restOut: McpCustomToolRow[] = restOps.map((op) => ({ + name: toolNameFromOperation(String(op.name)), + description: String(op.name), + inputSchema: { type: 'object' }, + })) + return { + tools: restOut, + source: 'services_operations', + expoId: expo.id, + serviceId, + } +} + +export async function resolveMcpCustomToolsFromUrl( + mcpUrl: string, + client: McpCustomToolsClient, +): Promise { + const { orgId, serviceName, version, host } = parseMcpUrl(mcpUrl) + const services = (await listAllServices(client)) as ServiceRow[] + const service = services.find( + (s) => + s && + s.organizationId === orgId && + s.name === serviceName && + s.version === version, + ) + if (!service?.id) { + throw new Error(`Service not found for ${orgId} / ${serviceName} / ${version}`) + } + const expoRaw = await resolveExpositionForService(client, service.id, host) + if (!expoRaw?.id) { + throw new Error( + 'No exposition (active or full list) has FQDNs matching the MCP URL host for this service', + ) + } + return resolveMcpCustomToolsForService(service.id, client, { exposition: expoRaw }) +} diff --git a/web-ui/src/lib/mcpEndpointUrls.ts b/web-ui/src/lib/mcpEndpointUrls.ts new file mode 100644 index 0000000..3bf72ea --- /dev/null +++ b/web-ui/src/lib/mcpEndpointUrls.ts @@ -0,0 +1,155 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Construction des URLs MCP à partir des DTO expositions actives du control-plane, + * alignée sur la CLI Reshapr : {@link https://github.com/reshaprio/reshapr/blob/main/cli/src/utils/format.ts formatEndpoint} + * et la liste {@link https://github.com/reshaprio/reshapr/blob/main/cli/src/commands/expo.ts expo list} (expositions actives). + * + * Aucune lecture BDD ni CLI : uniquement les réponses JSON des routes `/api/v1/expositions/...`. + */ + +/** Même encodage que `encodeUrl` dans format.ts (espaces → '+'). */ +export function encodeMcpPathSegment(value: string): string { + return value.replace(/\s/g, '+') +} + +/** + * Équivalent à `formatEndpoint` + normalisation en URL HTTP absolue pour le navigateur + * (les FQDN API sont souvent `hôte:port` sans schéma, comme dans la sortie CLI). + */ +export function buildAbsoluteMcpUrl( + fqdn: string, + organizationId: string, + serviceName: string, + serviceVersion: string, +): string { + const relPath = `mcp/${organizationId}/${encodeMcpPathSegment(serviceName)}/${encodeMcpPathSegment(serviceVersion)}` + const raw = fqdn.trim() + if (!raw) throw new Error('FQDN vide') + if (/^https?:\/\//i.test(raw)) { + const base = raw.replace(/\/+$/, '') + return new URL(relPath, `${base}/`).href + } + return new URL(relPath, `http://${raw.replace(/^\/+/, '')}/`).href +} + +export type McpUrlListItem = { + url: string + expositionId: string + organizationId: string + serviceId: string + serviceName: string + serviceVersion: string + fqdn: string + gatewayName?: string +} + +type GatewayRow = { name?: string; fqdns?: unknown[] } + +type ActiveExpositionLike = { + id?: string + organizationId?: string + service?: { id?: string; name?: string; version?: string } + gateways?: GatewayRow[] +} + +function rowsFromActivePayload(payload: unknown): ActiveExpositionLike[] { + if (Array.isArray(payload)) return payload as ActiveExpositionLike[] + if (payload && typeof payload === 'object') return [payload as ActiveExpositionLike] + return [] +} + +/** À partir d’objets au même format que `GET /api/v1/expositions/active` (liste ou détail actif). */ +export function collectMcpUrlsFromActiveExpositions(payload: unknown): McpUrlListItem[] { + const rows = rowsFromActivePayload(payload) + const out: McpUrlListItem[] = [] + for (const row of rows) { + const expositionId = String(row.id || '') + const organizationId = String(row.organizationId || '') + const svc = row.service + const serviceId = String(svc?.id || '') + const serviceName = String(svc?.name || '') + const serviceVersion = String(svc?.version || '') + if (!expositionId || !organizationId || !serviceId || !serviceName) continue + const gateways = Array.isArray(row.gateways) ? row.gateways : [] + for (const g of gateways) { + const fqdns = Array.isArray(g?.fqdns) ? g.fqdns : [] + const gatewayName = typeof g?.name === 'string' ? g.name : undefined + for (const fq of fqdns) { + if (typeof fq !== 'string' || !fq.trim()) continue + try { + out.push({ + url: buildAbsoluteMcpUrl(fq, organizationId, serviceName, serviceVersion), + expositionId, + organizationId, + serviceId, + serviceName, + serviceVersion, + fqdn: fq.trim(), + gatewayName, + }) + } catch { + // FQDN invalide : on ignore cette entrée + } + } + } + } + return out +} + +export type ListMcpUrlsDeps = { + listExpositionsActive: () => Promise + listExpositionsAll: () => Promise + getActiveExpositionOrNull: (id: string) => Promise +} + +/** + * - `active` : un seul appel `GET /api/v1/expositions/active` (équivalent `expo list` sans `--all`). + * - `all` : `GET /api/v1/expositions` puis pour chaque id `GET /api/v1/expositions/active/{id}` (404 ignoré), + * comme quand la CLI affiche les endpoints seulement pour une exposition réellement active. + */ +function dedupeByUrl(items: McpUrlListItem[]): McpUrlListItem[] { + const seen = new Set() + const out: McpUrlListItem[] = [] + for (const item of items) { + if (seen.has(item.url)) continue + seen.add(item.url) + out.push(item) + } + return out +} + +export async function listMcpEndpointUrls(mode: 'active' | 'all', deps: ListMcpUrlsDeps): Promise { + if (mode === 'active') { + const rows = await deps.listExpositionsActive() + return dedupeByUrl(collectMcpUrlsFromActiveExpositions(Array.isArray(rows) ? rows : [])) + } + + const all = await deps.listExpositionsAll() + const list = Array.isArray(all) ? all : [] + const merged: McpUrlListItem[] = [] + for (const raw of list) { + if (!raw || typeof raw !== 'object' || !('id' in raw)) continue + const id = String((raw as { id: unknown }).id || '') + if (!id) continue + const active = await deps.getActiveExpositionOrNull(id) + if (active === null) continue + merged.push(...collectMcpUrlsFromActiveExpositions(active)) + } + + return dedupeByUrl(merged) +} diff --git a/web-ui/src/lib/mcpPrompts.ts b/web-ui/src/lib/mcpPrompts.ts new file mode 100644 index 0000000..8f3c7cf --- /dev/null +++ b/web-ui/src/lib/mcpPrompts.ts @@ -0,0 +1,193 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Resolves MCP prompts from an MCP URL via control-plane REST + * (artifact `RESHAPR_PROMPTS`), avoiding browser CORS on the MCP gateway. + */ + +import type { McpPromptArgument, McpPromptDescriptor } from './mcpTypes.js'; +import { parseMcpUrl } from './mcpUrl.js'; + +export type McpPromptsClient = { + listServicesPage: (page: number, size: number) => Promise + listArtifactsByService: (serviceId: string) => Promise +} + +export type McpPromptArtifactYaml = { + name: string + content: string +} + +export type McpPromptsResolution = { + prompts: McpPromptDescriptor[] + source: 'artifacts_prompts' + serviceId: string + artifactNames: string[] + /** Full YAML content per `RESHAPR_PROMPTS` artifact. */ + artifactYamls: McpPromptArtifactYaml[] +} + +type ServiceRow = { + id?: string + organizationId?: string + name?: string + version?: string +} + +type ArtifactRow = { + type?: string + name?: string + content?: string | null +} + +async function listAllServices(client: McpPromptsClient): Promise { + const size = 100 + let page = 0 + const out: unknown[] = [] + for (;;) { + const batch = await client.listServicesPage(page, size) + if (!Array.isArray(batch) || batch.length === 0) break + out.push(...batch) + if (batch.length < size) break + page += 1 + } + return out +} + +function parsePromptArguments(block: string): McpPromptArgument[] { + const argsMarker = block.match(/^\s{4}arguments:\s*$/m) + if (!argsMarker || argsMarker.index === undefined) return [] + + const after = block.slice(argsMarker.index + argsMarker[0].length) + const args: McpPromptArgument[] = [] + const items = after.split(/^\s{6}-\s+/m).slice(1) + for (const item of items) { + const name = item.match(/^\s*name:\s*(.+)$/m)?.[1]?.trim() + if (!name) continue + const description = item.match(/^\s*description:\s*(.+)$/m)?.[1]?.trim() + const requiredLine = item.match(/^\s*required:\s*(true|false)\s*$/m)?.[1] + const arg: McpPromptArgument = { name } + if (description) arg.description = description + if (requiredLine === 'true') arg.required = true + args.push(arg) + } + return args +} + +/** Parse `prompts:` block from a Reshapr Prompts YAML artifact. */ +export function parseReshaprPromptsYaml(content: string): McpPromptDescriptor[] { + if (!content || typeof content !== 'string') return [] + const marker = 'prompts:' + const idx = content.indexOf(marker) + if (idx === -1) return [] + + let rest = content.slice(idx + marker.length).replace(/^\s*\n/, '') + const keyRe = /^ ([a-zA-Z0-9_]+):\s*$/gm + const keys: { name: string; endHeader: number; start: number }[] = [] + let mm: RegExpExecArray | null + while ((mm = keyRe.exec(rest)) !== null) { + keys.push({ name: mm[1], start: mm.index, endHeader: mm.index + mm[0].length }) + } + + const prompts: McpPromptDescriptor[] = [] + for (let i = 0; i < keys.length; i++) { + const block = rest.slice(keys[i].endHeader, i + 1 < keys.length ? keys[i + 1].start : rest.length) + const titleLine = block.match(/^\s{4}title:\s*(.+)$/m) + let description = '' + const folded = block.match(/^\s{4}description:\s*>\s*\n([\s\S]*?)(?=^\s{4}(?:arguments|title|result):)/m) + if (folded) { + description = folded[1] + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .join(' ') + .trim() + } else { + const inlineD = block.match(/^\s{4}description:\s*(.+)$/m) + if (inlineD) description = inlineD[1].trim() + } + const arguments_ = parsePromptArguments(block) + const prompt: McpPromptDescriptor = { + name: keys[i].name, + description: description || (titleLine ? titleLine[1].trim() : undefined), + } + if (arguments_.length) prompt.arguments = arguments_ + prompts.push(prompt) + } + return prompts +} + +export async function resolveMcpPromptsForService( + serviceId: string, + client: McpPromptsClient, +): Promise { + const artifacts = (await client.listArtifactsByService(serviceId)) as ArtifactRow[] + const promptArtifacts = (Array.isArray(artifacts) ? artifacts : []).filter( + (a) => a?.type === 'RESHAPR_PROMPTS' && a.content, + ) + if (promptArtifacts.length === 0) { + return { + prompts: [], + source: 'artifacts_prompts', + serviceId, + artifactNames: [], + artifactYamls: [], + } + } + + const byName = new Map() + const artifactNames: string[] = [] + const artifactYamls: McpPromptArtifactYaml[] = [] + for (const artifact of promptArtifacts) { + const label = artifact.name || `RESHAPR_PROMPTS-${artifactYamls.length + 1}` + if (artifact.name) artifactNames.push(artifact.name) + if (artifact.content) { + artifactYamls.push({ name: label, content: artifact.content }) + } + for (const p of parseReshaprPromptsYaml(artifact.content || '')) { + byName.set(p.name, p) + } + } + + return { + prompts: [...byName.values()], + source: 'artifacts_prompts', + serviceId, + artifactNames, + artifactYamls, + } +} + +export async function resolveMcpPromptsFromUrl( + mcpUrl: string, + client: McpPromptsClient, +): Promise { + const { orgId, serviceName, version } = parseMcpUrl(mcpUrl) + const services = (await listAllServices(client)) as ServiceRow[] + const service = services.find( + (s) => + s && + s.organizationId === orgId && + s.name === serviceName && + s.version === version, + ) + if (!service?.id) { + throw new Error(`Service not found for ${orgId} / ${serviceName} / ${version}`) + } + + return resolveMcpPromptsForService(service.id, client) +} diff --git a/web-ui/src/lib/mcpTypes.ts b/web-ui/src/lib/mcpTypes.ts new file mode 100644 index 0000000..508b1c7 --- /dev/null +++ b/web-ui/src/lib/mcpTypes.ts @@ -0,0 +1,27 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type McpPromptArgument = { + name: string; + description?: string; + required?: boolean; +}; + +export type McpPromptDescriptor = { + name: string; + description?: string; + arguments?: McpPromptArgument[]; +}; diff --git a/web-ui/src/lib/mcpUrl.ts b/web-ui/src/lib/mcpUrl.ts new file mode 100644 index 0000000..cd94119 --- /dev/null +++ b/web-ui/src/lib/mcpUrl.ts @@ -0,0 +1,39 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Parse MCP exposition URL path: /mcp/{organization}/{service}/{version} */ + +export function parseMcpUrl(mcpUrl: string): { + orgId: string + serviceName: string + version: string + host: string +} { + let u: URL + try { + u = new URL(mcpUrl) + } catch { + throw new Error('Invalid MCP URL') + } + const parts = u.pathname.split('/').filter(Boolean) + if (parts.length < 4 || String(parts[0]).toLowerCase() !== 'mcp') { + throw new Error('Expected MCP path: /mcp/{organization}/{service}/{version}') + } + const orgId = decodeURIComponent(parts[1].replace(/\+/g, '%20')) + const serviceName = decodeURIComponent(parts[2].replace(/\+/g, '%20')) + const version = decodeURIComponent(parts.slice(3).join('/').replace(/\+/g, '%20')) + return { orgId, serviceName, version, host: u.host } +} diff --git a/web-ui/src/lib/operationsList.ts b/web-ui/src/lib/operationsList.ts new file mode 100644 index 0000000..0133770 --- /dev/null +++ b/web-ui/src/lib/operationsList.ts @@ -0,0 +1,42 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Parse CLI-style operation lists (--io / --eo): JSON array or one operation per line. */ +export function parseOperationsList(text: string): string[] { + const trimmed = text.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith('[')) { + const parsed = JSON.parse(trimmed) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('Operations must be a JSON array of strings.'); + } + return parsed.map(String).filter((s) => s.length > 0); + } + + return trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); +} + +export function formatOperationsList(ops: unknown): string { + if (ops == null) return ''; + if (Array.isArray(ops)) { + return ops.map(String).filter(Boolean).join('\n'); + } + return ''; +} diff --git a/web-ui/src/lib/server/auth.ts b/web-ui/src/lib/server/auth.ts index 6e5b342..fa7ac52 100644 --- a/web-ui/src/lib/server/auth.ts +++ b/web-ui/src/lib/server/auth.ts @@ -78,10 +78,7 @@ export function getSessionToken(cookies: Cookies): string | null { return cookies.get(SESSION_COOKIE) ?? null; } -/** - * Extract user profile from a JWT token string. - * Returns { username, email, org } or null if decoding fails. - */ +/** Extract user profile from a JWT token string. */ export function extractUserProfile(token: string): { username: string; email: string; org: string } | null { const payload = decodeJwtPayload(token); if (!payload) return null; @@ -95,3 +92,42 @@ export function extractUserProfile(token: string): { username: string; email: st return { username, email: email ?? '', org }; } +function readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((g): g is string => typeof g === 'string'); +} + +function readRealmAccessRoles(payload: Record): string[] { + const realmAccess = payload.realm_access; + if (!realmAccess || typeof realmAccess !== 'object') return []; + return readStringArray((realmAccess as Record).roles); +} + +/** Extended session fields for the Account page (display only; no signature verification). */ +export function extractSessionClaims(token: string): { + groups: string[]; + roles: string[]; + expiresAt: string | null; + expired: boolean; +} { + const payload = decodeJwtPayload(token); + if (!payload) { + return { groups: [], roles: [], expiresAt: null, expired: false }; + } + + const groups = readStringArray(payload.groups); + const roles = [ + ...readStringArray(payload.roles), + ...readRealmAccessRoles(payload) + ]; + + let expiresAt: string | null = null; + let expired = false; + if (typeof payload.exp === 'number' && Number.isFinite(payload.exp)) { + expiresAt = new Date(payload.exp * 1000).toISOString(); + expired = payload.exp * 1000 <= Date.now(); + } + + return { groups, roles, expiresAt, expired }; +} + diff --git a/web-ui/src/lib/server/proxy.ts b/web-ui/src/lib/server/proxy.ts index 9ec39e1..1e92fba 100644 --- a/web-ui/src/lib/server/proxy.ts +++ b/web-ui/src/lib/server/proxy.ts @@ -67,14 +67,11 @@ export async function proxyRequest( const init: RequestInit = { method: request.method, - headers + headers, + // Preserve the original body stream (required for multipart FormData uploads). + ...(request.method !== 'GET' && request.method !== 'HEAD' ? { body: request.body, duplex: 'half' as const } : {}) }; - // Forward body for methods that have one. - if (!['GET', 'HEAD'].includes(request.method)) { - init.body = await request.text(); - } - const res = await fetch(targetUrl, init); // Stream the response back with original status and content-type. diff --git a/web-ui/src/lib/serviceContext.ts b/web-ui/src/lib/serviceContext.ts new file mode 100644 index 0000000..433008e --- /dev/null +++ b/web-ui/src/lib/serviceContext.ts @@ -0,0 +1,28 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ServiceRecord } from '$lib/serviceHub.js'; + +export const SERVICE_CONTEXT_KEY = Symbol('service-context'); + +export type ServiceContextValue = { + id: string; + service: ServiceRecord | null; + raw: unknown; + loading: boolean; + error: string | null; + refresh: () => Promise; +}; diff --git a/web-ui/src/lib/serviceHub.ts b/web-ui/src/lib/serviceHub.ts new file mode 100644 index 0000000..5e30864 --- /dev/null +++ b/web-ui/src/lib/serviceHub.ts @@ -0,0 +1,125 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { apiClient } from '$lib/api/client.js'; +import { resolveMcpCustomToolsForService } from '$lib/mcpCustomTools.js'; +import { resolveMcpPromptsForService } from '$lib/mcpPrompts.js'; +import { collectMcpUrlsFromActiveExpositions } from '$lib/mcpEndpointUrls.js'; + +export type ServiceApi = ReturnType; + +export type ServiceRecord = { + id: string; + name: string; + version: string; + type: string; + organizationId: string | null; + operationsCount: number; +}; + +export type ServiceHubSummary = { + service: ServiceRecord; + artifactCount: number; + planCount: number; + expositionActiveCount: number; + expositionAllCount: number; + mcpCustomToolsCount: number | null; + mcpPromptsCount: number | null; + mcpUrlCount: number; +}; + +function asRecord(raw: unknown): Record | null { + return raw && typeof raw === 'object' ? (raw as Record) : null; +} + +export function parseServiceRecord(raw: unknown): ServiceRecord | null { + const o = asRecord(raw); + if (!o || typeof o.id !== 'string') return null; + const ops = o.operations; + const operationsCount = Array.isArray(ops) ? ops.length : 0; + return { + id: o.id, + name: typeof o.name === 'string' ? o.name : '—', + version: typeof o.version === 'string' ? o.version : '—', + type: o.type != null ? String(o.type) : '—', + organizationId: typeof o.organizationId === 'string' ? o.organizationId : null, + operationsCount + }; +} + +export function expositionBelongsToService(raw: unknown, serviceId: string): boolean { + const o = asRecord(raw); + const svc = asRecord(o?.service); + return svc?.id === serviceId; +} + +export function planBelongsToService(raw: unknown, serviceId: string): boolean { + const o = asRecord(raw); + return o?.serviceId === serviceId; +} + +export async function loadServiceHubSummary( + serviceId: string, + client: ServiceApi, +): Promise { + const raw = await client.getService(serviceId); + const service = parseServiceRecord(raw); + if (!service) { + throw new Error(`Service not found: ${serviceId}`); + } + + const [artifacts, plans, activeExpos, allExpos] = await Promise.all([ + client.listArtifactsByService(serviceId), + client.listConfigurationPlans(), + client.listExpositionsActive(), + client.listExpositionsAll() + ]); + + const artifactList = Array.isArray(artifacts) ? artifacts : []; + const planList = (Array.isArray(plans) ? plans : []).filter((p) => + planBelongsToService(p, serviceId), + ); + const activeList = (Array.isArray(activeExpos) ? activeExpos : []).filter((e) => + expositionBelongsToService(e, serviceId), + ); + const allList = (Array.isArray(allExpos) ? allExpos : []).filter((e) => + expositionBelongsToService(e, serviceId), + ); + + const mcpUrls = collectMcpUrlsFromActiveExpositions(activeList); + + let mcpCustomToolsCount: number | null = null; + let mcpPromptsCount: number | null = null; + try { + const tools = await resolveMcpCustomToolsForService(serviceId, client); + mcpCustomToolsCount = tools.tools.length; + } catch { + mcpCustomToolsCount = null; + } + const prompts = await resolveMcpPromptsForService(serviceId, client); + mcpPromptsCount = prompts.prompts.length; + + return { + service, + artifactCount: artifactList.length, + planCount: planList.length, + expositionActiveCount: activeList.length, + expositionAllCount: allList.length, + mcpCustomToolsCount, + mcpPromptsCount, + mcpUrlCount: mcpUrls.length + }; +} diff --git a/web-ui/src/lib/types.ts b/web-ui/src/lib/types.ts index f18bd7c..840b8b6 100644 --- a/web-ui/src/lib/types.ts +++ b/web-ui/src/lib/types.ts @@ -27,6 +27,10 @@ export interface User { username: string; email: string; org: string; + groups?: string[]; + roles?: string[]; + expiresAt?: string | null; + expired?: boolean; } /** User profile returned by /api/v1/user/profile. */ diff --git a/web-ui/src/lib/utils/relativeAge.ts b/web-ui/src/lib/utils/relativeAge.ts new file mode 100644 index 0000000..84a9db6 --- /dev/null +++ b/web-ui/src/lib/utils/relativeAge.ts @@ -0,0 +1,36 @@ +/* + * Copyright The Reshapr Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Relative age from an ISO-8601 instant (e.g. control plane `LocalDateTime` JSON). */ +export function formatRelativeAge(createdOn: string | undefined): string { + if (!createdOn) return '—'; + const t = Date.parse(createdOn); + if (Number.isNaN(t)) return '—'; + const diffMs = Date.now() - t; + if (diffMs < 0) return '0s'; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m`; + const h = Math.floor(min / 60); + if (h < 48) return `${h}h`; + const d = Math.floor(h / 24); + if (d < 90) return `${d}d`; + const mo = Math.floor(d / 30); + if (mo < 24) return `${mo}mo`; + const y = Math.floor(d / 365); + return `${y}y`; +} diff --git a/web-ui/src/routes/(app)/+layout.svelte b/web-ui/src/routes/(app)/+layout.svelte index 5f5f448..ec4ff5a 100644 --- a/web-ui/src/routes/(app)/+layout.svelte +++ b/web-ui/src/routes/(app)/+layout.svelte @@ -21,6 +21,8 @@ import { auth } from '$lib/stores/auth.svelte.js'; import { sidebar } from '$lib/stores/sidebar.svelte.js'; import { getBootstrapConfig } from '$lib/api/config.js'; + import { cn } from '$lib/utils.js'; + import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import { HugeiconsIcon } from '@hugeicons/svelte'; import { DashboardSquare01Icon, @@ -29,7 +31,9 @@ SidebarLeft01Icon, SidebarRight01Icon, ChevronDownIcon, - Building01Icon + Building01Icon, + UserIcon, + FlaskConicalIcon } from '@hugeicons/core-free-icons'; let { children } = $props(); @@ -37,6 +41,19 @@ let version = $state(''); let userMenuOpen = $state(false); let orgSelectorOpen = $state(false); + let experimentalOpen = $state(false); + + const experimentalNav = [ + { href: '/artifacts', label: 'Artifacts' }, + { href: '/plans', label: 'Plans' }, + { href: '/expositions', label: 'Expositions' }, + { href: '/mcp-custom-tools', label: 'MCP custom tools' }, + { href: '/mcp-prompts', label: 'MCP prompts' }, + { href: '/secrets', label: 'Secrets' }, + { href: '/gateway-groups', label: 'Gateway groups' }, + { href: '/quotas', label: 'Quotas' }, + { href: '/api-tokens', label: 'API tokens' }, + ] as const; onMount(async () => { const hasSession = await auth.initSession(); @@ -71,6 +88,7 @@ items: [ { href: '/', label: 'Dashboard', icon: DashboardSquare01Icon }, { href: '/services', label: 'Services', icon: ApiIcon }, + { href: '/account', label: 'Account', icon: UserIcon }, ] }, { @@ -85,7 +103,32 @@ function isActive(href: string): boolean { const path = page.url.pathname; if (href === '/') return path === '/'; - return path.startsWith(href); + if (href === '/services') { + return path === '/services' || path.startsWith('/services/'); + } + if (href === '/plans') { + return path === '/plans' || path.startsWith('/plans/'); + } + return path === href || path.startsWith(href + '/'); + } + + function isExperimentalActive(): boolean { + return experimentalNav.some((item) => isActive(item.href)); + } + + $effect(() => { + if (isExperimentalActive()) { + experimentalOpen = true; + } + }); + + function experimentalNavClass(href: string): string { + return cn( + 'block rounded-md px-2 py-1.5 text-sm transition-colors', + isActive(href) + ? 'bg-sidebar-accent font-medium text-sidebar-accent-foreground' + : 'text-sidebar-foreground/80 hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground' + ); } function handleSignOut() { @@ -241,6 +284,32 @@ {/each} {/if} {/each} + + {#if !sidebar.collapsed} + + + + + + Experimental + + + + + + {#each experimentalNav as item (item.href)} + {item.label} + {/each} + + + {/if} @@ -293,7 +362,7 @@
-
+
{@render children()}
diff --git a/web-ui/src/routes/(app)/+page.svelte b/web-ui/src/routes/(app)/+page.svelte index ef540f0..822bf09 100644 --- a/web-ui/src/routes/(app)/+page.svelte +++ b/web-ui/src/routes/(app)/+page.svelte @@ -15,49 +15,232 @@ --> - Dashboard — reShapr + Dashboard — reShapr -
-
-
-

Dashboard

-

- Welcome back, {auth.user?.username}. - You're working in the {auth.user?.org} organization. -

-
- - -
+ + {#snippet actions()} + + {/snippet} + + +

+ Summary for your organization ({auth.currentOrg}), using v1 APIs exposed by the control plane. +

+ +{#if error} + +{/if} + +
+ + + + + Services + + + +

{fmt(stats?.serviceCount)}

+

Registered (current organization)

+ + View services + +
+
+ + + + Gateways registered + + + +

{fmt(stats?.gatewayRegisteredCount)}

+

Quota or active expositions (see breakdown)

+ {#if stats?.gatewayRegisteredDetail} + {@const d = stats.gatewayRegisteredDetail} + + + {gatewayDetailOpen ? 'Hide' : 'Show'} calculation breakdown + + +

+ Displayed source: + {gatewaySourceLabel[d.source]} +

+ {#if d.quota} +
+

Quota gateway.count

+
    +
  • used = limit − remaining = {d.quota.limit} − {d.quota.remaining} = + {d.quota.used}
  • +
+
+ {:else} +

Quota gateway.count not available.

+ {/if} +
+

+ GET /api/v1/expositions/active — gateways deduplicated by id/name +

+

+ {d.fromActiveExpositions.registered} unique gateway(s), {d.fromActiveExpositions.healthy} + with FQDN (healthy) +

+ {#if d.fromActiveExpositions.gateways.length === 0} +

No gateways on active expositions.

+ {:else} +
    + {#each d.fromActiveExpositions.gateways as gw (gw.key)} +
  • + {gw.key} + {#if gw.name && gw.name !== gw.key} + — {gw.name} + {/if} + + · FQDN: {gw.hasFqdn ? 'yes' : 'no'} + +
    + + expositions: {gw.onActiveExpositions.join(', ')} + +
  • + {/each} +
+ {/if} +
+

+ Card value = {#if d.quota} + max({d.quota.used}, {d.fromActiveExpositions.registered}) = {d.displayedCount} + {:else} + {d.fromActiveExpositions.registered} + {/if} +

+
+
+ {/if} +
+
+ + + + Gateways healthy + + + +

{fmt(stats?.gatewayHealthyCount)}

+

Active expositions + FQDN

+
+
+ + + + Gateway groups + + + +

{fmt(stats?.gatewayGroupsCount)}

+

Via quotas (used)

+
+
+ + + + Expositions + + + +

{fmt(stats?.expositionCount)}

+

Via quotas (used)

+
+
+
+ +
+ + + + +
diff --git a/web-ui/src/routes/(app)/account/+page.svelte b/web-ui/src/routes/(app)/account/+page.svelte new file mode 100644 index 0000000..eed850e --- /dev/null +++ b/web-ui/src/routes/(app)/account/+page.svelte @@ -0,0 +1,141 @@ + + + + + + Account — reShapr + + +
+ + +

+ Profile information is read from your server session. The control plane validates access on each API call. +

+ + {#if !auth.user} + + Unable to read session + + No active session. Sign out and sign in again. + + + {:else} + {#if auth.user.expired} + + Session expired + + Your session has expired. Sign out and sign in again to continue. + + + {/if} + +
+ + + Identity + + +
+

Username

+

{auth.user.username ?? '—'}

+
+
+

Email

+

{auth.user.email ?? '—'}

+
+
+

Groups

+

+ {auth.user.groups && auth.user.groups.length > 0 ? auth.user.groups.join(', ') : '—'} +

+
+
+

Platform admin (UI)

+

{auth.isAdmin ? 'Yes' : 'No'}

+
+
+
+ + + + Tenant & session + + +
+

Organization

+

+ {#if auth.user.org} + {auth.user.org} + {:else} + — + {/if} +

+
+
+

Roles

+

+ {auth.user.roles && auth.user.roles.length > 0 ? auth.user.roles.join(', ') : '—'} +

+
+
+

Session expires

+

{formatTokenExpiry(auth.user.expiresAt)}

+
+
+

Mode / version

+

+ {mode || '…'} + {#if version} + · {version} + {/if} +

+
+
+
+
+ {/if} +
diff --git a/web-ui/src/routes/(app)/admin/organizations/+page.svelte b/web-ui/src/routes/(app)/admin/organizations/+page.svelte index 85cfe5b..ef5038e 100644 --- a/web-ui/src/routes/(app)/admin/organizations/+page.svelte +++ b/web-ui/src/routes/(app)/admin/organizations/+page.svelte @@ -297,7 +297,7 @@ Admin — reShapr -
+
diff --git a/web-ui/src/routes/(app)/admin/organizations/UsersTab.svelte b/web-ui/src/routes/(app)/admin/organizations/UsersTab.svelte index 9074038..a14d8bf 100644 --- a/web-ui/src/routes/(app)/admin/organizations/UsersTab.svelte +++ b/web-ui/src/routes/(app)/admin/organizations/UsersTab.svelte @@ -16,7 +16,7 @@ + + + {#snippet actions()} + + {/snippet} + + +{#if createdToken} + + Token (copy once) + + {createdToken} + + +{/if} + +{#if error} + +{/if} + + + + Create + + +
+ + + + {validityDays} day{Number(validityDays) > 1 ? 's' : ''} + + + {#each VALIDITY as d (d)} + {d} day{d > 1 ? 's' : ''} + {/each} + + + +
+
+
+ +
+ + + + ID + Name + Valid until + + + + + {#each rows as t (t.id)} + + {t.id} + {t.name} + + {t.validUntil ? new Date(t.validUntil).toLocaleString() : '—'} + + + + + + {/each} + + +
diff --git a/web-ui/src/routes/(app)/artifacts/+page.svelte b/web-ui/src/routes/(app)/artifacts/+page.svelte new file mode 100644 index 0000000..151a4d7 --- /dev/null +++ b/web-ui/src/routes/(app)/artifacts/+page.svelte @@ -0,0 +1,634 @@ + + + + + + + + MCP server creation (recommended order) + +
    +
  1. Import + expose — spec, backend URL, optional --io.
  2. +
  3. Import only — if you split plan/exposition on Plans.
  4. +
  5. Custom tools — YAML kind: CustomTools.
  6. +
  7. MCP prompts — YAML kind: Prompts.
  8. +
  9. + Verify — + MCP custom tools, + MCP prompts. +
  10. +
+

+ Full guide: docs/MCP_SERVER_SETUP.md in this repository. +

+
+
+ +{#if err} + +{/if} +{#if msg} + + Success + {msg} + +{/if} +{#if importServiceApiKey} + + Plan API key (copy once) + + {importServiceApiKey} +

Save it now; it will not be shown again.

+
+
+{/if} + + + + + + 1. Import + expose (spec, plan, exposition) + + Like reshapr import -f|-u … --backendEndpoint … then plan + exposition. + Optional --io below. Needs a + gateway group. + + + + + +
+
+ Specification source +
+ + +
+
+ + {#if importSource === 'file'} +
+ + +
+ {:else} +
+ + +
+
+ + +
+ {/if} + + {#key importSource} +
+ + +
+ {/key} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +