diff --git a/src/api/deploy/deployments.js b/src/api/deploy/deployments.js index 188ba0b..9ebb502 100644 --- a/src/api/deploy/deployments.js +++ b/src/api/deploy/deployments.js @@ -13,8 +13,8 @@ export const getDeployment = async (token, id) => { }; export const getDeployments = async (token, all = false) => { - const allQuery = all ? "?all=true" : ""; - const url = `${process.env.REACT_APP_DEPLOY_API_URL}/deployments${allQuery}`; + const allQuery = all ? "&all=true" : ""; + const url = `${process.env.REACT_APP_DEPLOY_API_URL}/deployments?shared=true${allQuery}`; const res = await fetch(url, { method: "GET", headers: { diff --git a/src/api/deploy/teams.js b/src/api/deploy/teams.js index 593bdf1..0eb6542 100644 --- a/src/api/deploy/teams.js +++ b/src/api/deploy/teams.js @@ -57,6 +57,8 @@ export const createTeam = async (token, name, description) => { } throw res; } + + return await res.json(); }; export const deleteTeam = async (token, teamId) => { @@ -74,11 +76,43 @@ export const addMembers = async (token, teamId, members) => { const url = `${process.env.REACT_APP_DEPLOY_API_URL}/teams/${teamId}`; const body = { members: members }; - await fetch(url, { + let res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const body = await res.json(); + if (body) { + throw body; + } + throw res; + } + + return await res.json(); +}; + +export const updateTeam = async (token, teamId, body) => { + const url = `${process.env.REACT_APP_DEPLOY_API_URL}/teams/${teamId}`; + + let res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${token}`, }, body: JSON.stringify(body), }); + + if (!res.ok) { + const body = await res.json(); + if (body) { + throw body; + } + throw res; + } + + return await res.json(); }; diff --git a/src/api/deploy/vms.js b/src/api/deploy/vms.js index df536d6..9f7ee2d 100644 --- a/src/api/deploy/vms.js +++ b/src/api/deploy/vms.js @@ -14,8 +14,8 @@ export const getVM = async (token, id) => { }; export const getVMs = async (token, all = false) => { - const allQuery = all ? "?all=true" : ""; - const url = `${process.env.REACT_APP_DEPLOY_API_URL}/vms${allQuery}`; + const allQuery = all ? "&all=true" : ""; + const url = `${process.env.REACT_APP_DEPLOY_API_URL}/vms?shared=true${allQuery}`; const response = await fetch(url, { method: "GET", headers: { diff --git a/src/components/Gravatar.jsx b/src/components/Gravatar.jsx index 8955c3d..5389dd1 100644 --- a/src/components/Gravatar.jsx +++ b/src/components/Gravatar.jsx @@ -16,7 +16,6 @@ const Gravatar = ({ user, fallback, ...props }) => { const uri = encodeURI(`https://www.gravatar.com/avatar/${hash}?d=404`); const response = await fetch(uri); - console.log(response); if (response.ok) { return uri; } @@ -27,11 +26,9 @@ const Gravatar = ({ user, fallback, ...props }) => { const gravatarUri = await gravatar(); setHasFetched(true); if (gravatarUri) { - console.log("found gravatar: " + gravatarUri + " for user " + user.email); setUserAvatar(gravatarUri); return; } - console.log("no gravatar found for user " + user.email); }; useEffect(() => { @@ -42,7 +39,9 @@ const Gravatar = ({ user, fallback, ...props }) => { return ( - {!userAvatar && fallback ? fallback : (user.email || user.username)[0].toUpperCase()} + {!userAvatar && fallback + ? fallback + : (user.email || user.username)[0].toUpperCase()} ); }; diff --git a/src/contexts/ResourceContext.jsx b/src/contexts/ResourceContext.jsx index 4760448..e7f687f 100644 --- a/src/contexts/ResourceContext.jsx +++ b/src/contexts/ResourceContext.jsx @@ -89,6 +89,24 @@ export const ResourceContextProvider = ({ children }) => { } }; + const getTeamResourceIds = () => { + if (!teams) return; + + let resourceIds = []; + + teams.forEach((t) => { + if (t.resources) { + t.resources.forEach((r) => { + if (!resourceIds.includes(r.id)) { + resourceIds.push(r.id); + } + }); + } + }); + + return resourceIds; + }; + const queueJob = (job) => { console.log("Queuing job", JSON.stringify(job)); if (!job) return; @@ -106,7 +124,20 @@ export const ResourceContextProvider = ({ children }) => { setRows(array); - setUserRows(array.filter((row) => row.ownerId === user.id)); + let userOwned = array.filter((row) => row.ownerId === user.id); + + let teamResourceIds = getTeamResourceIds(); + teamResourceIds.forEach((resourceId) => { + let sharedRow = array.find((row) => row.id === resourceId); + + sharedRow.shared = true; + + if (sharedRow && !userOwned.includes(sharedRow)) { + userOwned.push(sharedRow); + } + }); + + setUserRows(userOwned); }; const loadZones = async () => { diff --git a/src/locales/en.json b/src/locales/en.json index 1e2b37e..afca44c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -391,7 +391,8 @@ "menu-manage-account": "Manage account", "teams": "Teams", "current-teams": "Current teams", - "teams-subheader": "Teams are a way to organize your resources and collaborate with others. You can create a team and invite others to join.", + "teams-subheader-1": "Teams are a way to organize your resources and collaborate with others. You can create a team and invite others to join.", + "teams-subheader-2": "Share resources by going to the resource's page and clicking share with team.", "create-team": "Create team", "description": "Description", "invite": "Invite", @@ -402,6 +403,22 @@ "replicas-saving": "Saving replicas...", "could-not-save-replicas": "Could not save replicas: ", "replicas-shutdown-warning": "This will shut down your deployment. Are you sure?", - "members": "Members" + "members": "Members", + "share-with-team-description": "Share with team", + "share-with-team-action": "share with team", + "search-for-teams": "Search for teams", + "share": "share", + "with": "with", + "transferred-successfully": "Successfully transferred", + "successfully-added-to-team": "Successfully added to team", + "resources": "Resources", + "resource": "Resource", + "remove": "Remove", + "from-team": "from team", + "resource-not-found": "Resource not found", + "shared": "Shared", + "shared-in-group": "Resource is available through a group you are a member of", + "auto-scroll": "Auto scroll", + "logs-truncated": "Over 1000 logs: Logs truncated. Please download the full log file to view earlier logs." } } \ No newline at end of file diff --git a/src/locales/se.json b/src/locales/se.json index 39d8120..b30b0fb 100644 --- a/src/locales/se.json +++ b/src/locales/se.json @@ -391,7 +391,8 @@ "menu-manage-account": "Hantera konto", "teams": "Grupper", "current-teams": "Dina grupper", - "teams-subheader": "Grupper är ett sätt att organisera dina resurser och samarbeta med andra. Du kan skapa en grupp och bjuda in andra.", + "teams-subheader-1": "Grupper är ett sätt att organisera dina resurser och samarbeta med andra. Du kan skapa en grupp och bjuda in andra.", + "teams-subheader-2": "Dela resurser med gruppen genom att besöka resursens sida och klicka på dela med grupp", "create-team": "Skapa grupp", "description": "Beskrivning", "invite": "Bjud in", @@ -402,6 +403,22 @@ "replicas-saving": "Sparar antal kopior...", "could-not-save-replicas": "Kunde inte uppdatera antal kopior: ", "replicas-shutdown-warning": "Varning: Om du väljer 0 kopior kommer appen att stängas av.", - "members": "Medlemmar" + "members": "Medlemmar", + "share-with-team-description": "Dela med grupp", + "share-with-team-action": "dela med grupp", + "search-for-teams": "Sök grupper", + "share": "dela", + "with": "med", + "transferred-successfully": "Överföringen lyckades", + "successfully-added-to-team": "Delad med grupp", + "resources": "Resurser", + "resource": "Resurs", + "remove": "Ta bort", + "from-team": "från grupp", + "resource-not-found": "Resursen hittades inte", + "shared": "Delad", + "shared-in-group": "Resursen är tillgänglig genom en av grupperna du är medlem i", + "auto-scroll": "Auto scroll", + "logs-truncated": "Över 1000 loggar: Loggar avkortade. Ladda ner hela loggen för att se tidigare loggar." } } \ No newline at end of file diff --git a/src/pages/deploy/Deploy.jsx b/src/pages/deploy/Deploy.jsx index 9e498ee..e606952 100644 --- a/src/pages/deploy/Deploy.jsx +++ b/src/pages/deploy/Deploy.jsx @@ -352,6 +352,22 @@ export function Deploy() { ); }; + const renderShared = (row) => { + if (!row.shared) return <>>; + + return ( + } + > + + {t("shared")} + + + ); + }; + useEffect(() => { if (user && !user.onboarded) { navigate("/onboarding"); @@ -455,6 +471,7 @@ export function Deploy() { {renderResourceStatus(row)} {renderStatusCode(row)} {renderZone(row)} + {renderShared(row)} diff --git a/src/pages/edit/DangerZone.jsx b/src/pages/edit/DangerZone.jsx index f2ef894..a4742b5 100644 --- a/src/pages/edit/DangerZone.jsx +++ b/src/pages/edit/DangerZone.jsx @@ -1,5 +1,5 @@ import { enqueueSnackbar } from "notistack"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { searchUsers } from "src/api/deploy/users"; import { errorHandler } from "src/utils/errorHandler"; @@ -8,6 +8,8 @@ import ConfirmButton from "src/components/ConfirmButton"; import { updateVM } from "src/api/deploy/vms"; import { Link } from "react-router-dom"; import { updateDeployment } from "src/api/deploy/deployments"; +import useResource from "src/hooks/useResource"; +import { updateTeam } from "src/api/deploy/teams"; const { Card, CardHeader, @@ -16,17 +18,25 @@ const { Autocomplete, TextField, Button, + Stack, } = require("@mui/material"); -const DangerZone = ({resource}) => { +const DangerZone = ({ resource }) => { const { t } = useTranslation(); - const [results, setResults] = useState([]); - const [users, setUsers] = useState([]); const { initialized, keycloak } = useKeycloak(); - const [selected, setSelected] = useState(''); - const [success, setSuccess] = useState(false); + const { teams } = useResource(); + + const [users, setUsers] = useState([]); + const [resultsUser, setResultsUser] = useState([]); + const [selectedUser, setSelectedUser] = useState(""); + + const [teamsList, setTeamsList] = useState([]); + const [resultsTeam, setResultsTeam] = useState([]); + const [selectedTeam, setSelectedTeam] = useState(""); + + const [transferred, setTransferred] = useState(false); - const search = async (query) => { + const userSearch = async (query) => { if (!initialized) return; try { let response = await searchUsers(keycloak.token, query); @@ -44,7 +54,7 @@ const DangerZone = ({resource}) => { options = [...new Set(options)]; options.sort((a, b) => a.localeCompare(b)); - setResults(options); + setResultsUser(options); } catch (error) { errorHandler(error).forEach((e) => enqueueSnackbar(t("search-error") + e, { @@ -54,15 +64,33 @@ const DangerZone = ({resource}) => { } }; + const teamSearch = async (query) => { + if (!teams) return; + + let options = []; + + teams.forEach((team) => { + if (!teamsList.find((t) => t.name === team.name)) { + setTeamsList((teamsList) => [...teamsList, team]); + } + options.push(team.name); + }); + + options = [...new Set(options)]; + options.sort((a, b) => a.localeCompare(b)); + setResultsTeam(options); + console.log(options); + }; + const updateOwner = async () => { if (!initialized) return; // find owner id of selected user - const selectedUser = users.find( - (user) => user.email === selected || user.username === selected + const selected = users.find( + (user) => user.email === selectedUser || user.username === selectedUser ); - if (!selectedUser) return; - const ownerId = selectedUser.id; + if (!selected) return; + const ownerId = selected.id; // update resource with body containing new owner id const body = { @@ -72,14 +100,55 @@ const DangerZone = ({resource}) => { let response; if (resource.type === "vm") { response = await updateVM(resource.id, body, keycloak.token); - } - else if (resource.type === "deployment") { + } else if (resource.type === "deployment") { response = await updateDeployment(resource.id, body, keycloak.token); } + if (response) { + setTransferred(true); + enqueueSnackbar(t("successfully-transferred"), { + variant: "success", + }); + } + } catch (error) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("update-error") + e, { + variant: "error", + }) + ); + } + }; + + const addToTeam = async () => { + if (!initialized) return; + + // find team id of selected team + const selected = teams.find( + (team) => team.name === selectedTeam || team.id === selectedTeam + ); + + let currentIds = []; + if (selected.resources) { + selected.resources.forEach((r) => { + currentIds.push(r.id); + }); + } + + if (currentIds.includes(resource.id)) { + currentIds = currentIds.filter((id) => id !== resource.id); + } + + const body = { + resources: [...currentIds, resource.id], + }; + + try { + let response = await updateTeam(keycloak.token, selected.id, body); if (response) { - setSuccess(true); + enqueueSnackbar(t("successfully-added-to-team"), { + variant: "success", + }); } } catch (error) { errorHandler(error).forEach((e) => @@ -90,6 +159,11 @@ const DangerZone = ({resource}) => { } }; + useEffect(() => { + teamSearch(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( { > - {success ? ( + {transferred ? ( <> - {t("successfully-transferred")} + + {t("successfully-transferred")} + - {t("back-to-dashboard")} + {t("back-to-dashboard")} > ) : ( - <> - {t("transfer-ownership")} - { - setSelected(value); - search(value); - }} - renderInput={(params) => ( - - )} - /> - - > + + + + {t("share-with-team-description")} + + { + setSelectedTeam(value); + teamSearch(value); + }} + renderInput={(params) => ( + + )} + /> + + + + {t("transfer-ownership")} + { + setSelectedUser(value); + userSearch(value); + }} + renderInput={(params) => ( + + )} + /> + + + )} diff --git a/src/pages/edit/Edit.jsx b/src/pages/edit/Edit.jsx index 2fb500e..273fc88 100644 --- a/src/pages/edit/Edit.jsx +++ b/src/pages/edit/Edit.jsx @@ -46,6 +46,7 @@ import { useTranslation } from "react-i18next"; import DangerZone from "./DangerZone"; import { ReplicaManager } from "./deployments/ReplicaManager"; import Iconify from "src/components/Iconify"; +import { enqueueSnackbar } from "notistack"; export function Edit() { const { t } = useTranslation(); @@ -55,6 +56,7 @@ export function Edit() { const [persistent, setPersistent] = useState([]); const { user, rows, initialLoad, zones } = useResource(); const [loaded, setLoaded] = useState(false); + const [reloads, setReloads] = useState(0); const allowedTypes = ["vm", "deployment"]; let { type, id } = useParams(); @@ -66,7 +68,14 @@ export function Edit() { const loadResource = () => { const row = rows.find((row) => row.id === id); - if (!row) return; + if (!row) { + setReloads(reloads + 1); + if (reloads > 3) { + enqueueSnackbar(t("resource-not-found"), { variant: "error" }); + navigate("/deploy"); + } + return; + } setResource(row); if (type === "deployment" && !loaded) { diff --git a/src/pages/edit/deployments/LogsView.jsx b/src/pages/edit/deployments/LogsView.jsx index 8b55058..4a99e55 100644 --- a/src/pages/edit/deployments/LogsView.jsx +++ b/src/pages/edit/deployments/LogsView.jsx @@ -9,7 +9,7 @@ import { Typography, } from "@mui/material"; import { useKeycloak } from "@react-keycloak/web"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import CopyToClipboard from "react-copy-to-clipboard"; import Iconify from "src/components/Iconify"; import polyfilledEventSource from "@sanity/eventsource"; @@ -19,11 +19,15 @@ export const LogsView = ({ deployment }) => { const { t } = useTranslation(); const { initialized, keycloak } = useKeycloak(); const [logs, setLogs] = useState([]); + const [viewableLogs, setViewableLogs] = useState([]); const [lineWrap, setLineWrap] = useState(true); const [compactMode, setCompactMode] = useState(false); + const [autoScroll, setAutoScroll] = useState(true); const [connection, setConnection] = useState("connecting"); const [sse, setSse] = useState(null); + const last = useRef(null); + const initSse = () => { if (!(deployment && initialized)) return; @@ -52,21 +56,21 @@ export const LogsView = ({ deployment }) => { initSse(); }, 5000); }; - + eventSource.addEventListener("deployment", (e) => { - setLogs((logs) => [e.data, ...logs]); + setLogs((logs) => [...logs, e.data]); }); - + eventSource.addEventListener("pod", (e) => { - setLogs((logs) => [e.data, ...logs]); + setLogs((logs) => [...logs, e.data]); }); - + eventSource.addEventListener("build", (e) => { - setLogs((logs) => [e.data, ...logs]); + setLogs((logs) => [...logs, e.data]); }); eventSource.onopen = (e) => { - console.log(e) + console.log(e); setConnection("connected"); }; }; @@ -76,14 +80,136 @@ export const LogsView = ({ deployment }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialized]); + const scrollParentToChild = (parent, child) => { + // borrowed from https://stackoverflow.com/a/45411081 + // Where is the parent on page + var parentRect = parent.getBoundingClientRect(); + // What can you see? + var parentViewableArea = { + height: parent.clientHeight, + width: parent.clientWidth, + }; + + // Where is the child + var childRect = child.getBoundingClientRect(); + // Is the child viewable? + var isViewable = + childRect.top >= parentRect.top && + childRect.bottom <= parentRect.top + parentViewableArea.height; + + // if you can't see the child try to scroll parent + if (!isViewable) { + // Should we scroll using top or bottom? Find the smaller ABS adjustment + const scrollTop = childRect.top - parentRect.top; + const scrollBot = childRect.bottom - parentRect.bottom; + if (Math.abs(scrollTop) < Math.abs(scrollBot)) { + // we're near the top of the list + parent.scrollTop += scrollTop; + } else { + // we're near the bottom of the list + parent.scrollTop += scrollBot; + } + } + }; + + useEffect( + () => { + if (logs.length > 1000) { + setViewableLogs(logs.slice(logs.length - 1000, logs.length)); + } else { + setViewableLogs(logs); + } + + if (autoScroll && last && last.current) { + // last.current.scroll.scrollIntoView(); + scrollParentToChild(last.current.parentNode, last.current); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [logs] + ); + if (!(deployment && logs && initialized)) return null; return ( - + + {t("logs-subheader") + + " " + + t("admin-showing") + + " " + + viewableLogs.length + + "/" + + logs.length}{" "} + {logs.length > 1000 && ( + <> + + {t("logs-truncated")} + > + )} + > + } + /> + + {logs.length > 1000 && ( + + {t("logs-truncated")} + + )} + + {viewableLogs.map((log, i) => ( + + {log} + + ))} + + {logs.length === 0 && ( + + {t("no-logs-found")} + + )} + + { label={t("compact-view")} /> + setAutoScroll(e.target.checked)} + inputProps={{ "aria-label": "controlled" }} + /> + } + label={t("auto-scroll")} + /> + } @@ -153,46 +290,6 @@ export const LogsView = ({ deployment }) => { {t("connection-status")}: {t(connection)} - - - {logs.map((log, i) => ( - - {log} - - ))} - - {logs.length === 0 && ( - - {t("no-logs-found")} - - )} - diff --git a/src/pages/teams/Teams.jsx b/src/pages/teams/Teams.jsx index 85def2c..b81dc6e 100644 --- a/src/pages/teams/Teams.jsx +++ b/src/pages/teams/Teams.jsx @@ -15,17 +15,25 @@ import { TableBody, TableCell, TableContainer, + TableHead, TableRow, TextField, Tooltip, Typography, } from "@mui/material"; import { useKeycloak } from "@react-keycloak/web"; +import { sentenceCase } from "change-case"; import { enqueueSnackbar } from "notistack"; -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { addMembers, createTeam, deleteTeam } from "src/api/deploy/teams"; +import { + addMembers, + createTeam, + deleteTeam, + updateTeam, +} from "src/api/deploy/teams"; import { searchUsers } from "src/api/deploy/users"; +import ConfirmButton from "src/components/ConfirmButton"; import Gravatar from "src/components/Gravatar"; import Iconify from "src/components/Iconify"; import JobList from "src/components/JobList"; @@ -134,6 +142,48 @@ const Teams = () => { } }; + const handleRemoveResource = async (team, resource) => { + if (!initialized) return; + + let currentIds = team.resources + .map((r) => r.id) + .filter((id) => id !== resource.id); + + let body = { + resources: currentIds, + }; + + try { + await updateTeam(keycloak.token, team.id, body); + setStale("removeResource " + resource.id + team.id); + } catch (error) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("update-error") + e, { + variant: "error", + }) + ); + } + }; + + const handleRemoveUser = async (team, user) => { + if (!initialized) return; + + let body = { + members: team.members.filter((member) => member.id !== user.id), + }; + + try { + await updateTeam(keycloak.token, team.id, body); + setStale("removeUser " + user.id + team.id); + } catch (error) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("update-error") + e, { + variant: "error", + }) + ); + } + }; + return ( <> {!user ? ( @@ -151,197 +201,349 @@ const Teams = () => { + {t("teams-subheader-1")} + + {t("teams-subheader-2")} + > + } /> - {teams.map((team, index) => ( - <> - {stale !== "delete " + team.id ? ( - <> - + {teams.map((team, index) => + stale !== "delete " + team.id ? ( + + - - - - {team.name} - - - {team.description} - - - - - - - {team.members.map((member) => ( - + expandedTeam === team.id + ? setExpandedTeam(null) + : setExpandedTeam(team.id) + } + > + + + + {team.name} + + + {team.description} + + + + + + + {team.members.map((member) => ( + + ))} + {team.members.length === 0 && ( + + - - - ))} - {team.members.length === 0 && ( - - - - - - )} - - {`${team.members.length} ${t("members")}`} - - - - - - - {expandedTeam === team.id && ( - + + + )} + + {`${team.members.length} ${t("members")}`} + + + + - + + {team.resources && + team.resources.length + + " " + + (team.resources.length > 1 || + team.resources.length === 0 + ? t("resources") + : t("resource"))} + + + + + + + {expandedTeam === team.id && ( + + + - {team.members.map((member) => ( - - - {member.email || - member.username} - - - {member.teamRole} - - - { - member?.addedAt - ?.replace("T", " ") - ?.split(".")[0] - } + + + + {t("members")} - ))} - - - - + + {team.members.map((member) => ( + - { - setSelected(value); - search(value); - }} - renderInput={(params) => ( - + + + ) : ( + <> + + {member.email || + member.username} + + + {sentenceCase( + member.teamRole + )} + + + {sentenceCase( + member.memberStatus )} - InputProps={{ - ...params.InputProps, - type: "search", + + - )} - /> - invite(team)} - variant="contained" - startIcon={ - - } - > - {t("invite")} - - - - handleDelete(team) - } - color="error" - startIcon={ - + > + { + member?.addedAt + ?.replace("T", " ") + ?.split(".")[0] + } + + + + handleRemoveUser(team, member) + } + props={{ + color: "error", + startIcon: ( + + ), + }} + /> + + > + )} + + ))} + + + + - {t("button-delete")} - - - - + { + setSelected(value); + search(value); + }} + renderInput={(params) => ( + + )} + /> + invite(team)} + variant="contained" + startIcon={ + + } + > + {t("invite")} + + + + handleDelete(team) + } + props={{ + color: "error", + startIcon: ( + + ), + }} + /> + + + + - - - )} - > - ) : ( - - - - - - )} - > - ))} + {team.resources && + team.resources.length > 0 && ( + + + + + + {t("resources")} + + + + + {team.resources.map((r) => ( + + {stale === + "removeResource " + + r.id + + team.id ? ( + + + + ) : ( + <> + + {r.name} + + + {r.type} + + + + handleRemoveResource( + team, + r + ) + } + props={{ + color: "error", + startIcon: ( + + ), + }} + /> + + > + )} + + ))} + + + + )} + + + + )} + + ) : ( + + + + + + ) + )} {stale === "created" && (
+ {t("logs-truncated")} +
+ {log} +
+ {t("no-logs-found")} +
- {log} -
- {t("no-logs-found")} -