Merge pull request #17 from JBB0807/save-backend-b
Working save and deploy
This commit is contained in:
commit
771f1e680c
15 changed files with 1235 additions and 86008 deletions
|
|
@ -4,11 +4,67 @@ const axios = require("axios");
|
||||||
const bcrypt = require("bcrypt");
|
const bcrypt = require("bcrypt");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
const DB_ASSIGNMENT_SERVICE_URL = process.env.DB_ASSIGNMENT_SERVICE_URL;
|
const DB_ASSIGNMENT_SERVICE_URL = process.env.DB_ASSIGNMENT_SERVICE_URL;
|
||||||
|
const DEPLOY_API_URL = process.env.DEPLOY_API_URL || "http://localhost:3600";
|
||||||
|
|
||||||
|
studentRouter.post("/save", async (req, res) => {
|
||||||
|
//get the app name and code and save the latest jupyter file in s3 bucket
|
||||||
|
const { appName, code } = req.body;
|
||||||
|
|
||||||
studentRouter.post("/save", (req, res) => {});
|
const notebook = {
|
||||||
|
cells: [
|
||||||
|
{
|
||||||
|
cell_type: "code",
|
||||||
|
execution_count: null,
|
||||||
|
metadata: {
|
||||||
|
language: "python"
|
||||||
|
},
|
||||||
|
outputs: [],
|
||||||
|
source: code.split('\n').map(line => line + '\n')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
kernelspec: {
|
||||||
|
display_name: "Python 3",
|
||||||
|
language: "python",
|
||||||
|
name: "python3"
|
||||||
|
},
|
||||||
|
language_info: {
|
||||||
|
name: "python",
|
||||||
|
version: "3.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nbformat: 4,
|
||||||
|
nbformat_minor: 5
|
||||||
|
};
|
||||||
|
|
||||||
studentRouter.post("/deploy", (req, res) => {});
|
// Convert the notebook object to a JSON string and then to base64
|
||||||
|
const jsonString = JSON.stringify(notebook, null, 2);
|
||||||
|
const base64 = Buffer.from(jsonString, 'utf-8').toString('base64');
|
||||||
|
|
||||||
|
const notebookName = `${Date.now()}-notebook.ipynb`;
|
||||||
|
console.log("DEPLOY_API_URL:", DEPLOY_API_URL);
|
||||||
|
await fetch(`${DEPLOY_API_URL}/${appName}/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ notebookName: notebookName, fileContentBase64: base64 })
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error("Failed to save notebook");
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
console.log("Notebook saved successfully:", data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error saving notebook:", error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
studentRouter.post("/deploy", (req, res) => {
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
studentRouter.get("/assignment/:qrnum", (req, res) => {
|
studentRouter.get("/assignment/:qrnum", (req, res) => {
|
||||||
const qrnum = req.params.qrnum;
|
const qrnum = req.params.qrnum;
|
||||||
|
|
@ -68,4 +124,18 @@ studentRouter.post("/verify", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// post restart from deployment service /appname/restart endpoint
|
||||||
|
studentRouter.post("/restart", async (req, res) => {
|
||||||
|
const { appName } = req.body;
|
||||||
|
console.log("Received request to restart app:", appName);
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${DEPLOY_API_URL}/${appName}/restart`);
|
||||||
|
console.log("Restart response:", response.data);
|
||||||
|
res.status(response.status).json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error restarting app:", error.message);
|
||||||
|
res.status(error.response?.status || 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = studentRouter;
|
module.exports = studentRouter;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
require('dotenv').config();
|
require("dotenv").config();
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
const passport = require("passport");
|
const passport = require("passport");
|
||||||
const session = require("express-session");
|
const session = require("express-session");
|
||||||
|
|
@ -13,7 +13,7 @@ const s3 = new AWS.S3({
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
region: process.env.AWS_REGION,
|
region: process.env.AWS_REGION,
|
||||||
s3ForcePathStyle: true
|
s3ForcePathStyle: true,
|
||||||
});
|
});
|
||||||
const BUCKET = process.env.COMMON_BUCKET;
|
const BUCKET = process.env.COMMON_BUCKET;
|
||||||
|
|
||||||
|
|
@ -48,21 +48,28 @@ app.use("/instructor", instructorRouter);
|
||||||
app.use("/student", studentRouter);
|
app.use("/student", studentRouter);
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.send("OK");
|
res.send("OK");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/notebook/save/:appname", async (req, res) => {
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/notebook/:appName", async (req, res) => {
|
app.get("/notebook/:appName", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { appName } = req.params;
|
const { appName } = req.params;
|
||||||
const prefix = `${appName}/notebooks/`;
|
const prefix = `${appName}/notebooks/`;
|
||||||
const list = await s3.listObjectsV2({ Bucket: BUCKET, Prefix: prefix }).promise();
|
const list = await s3
|
||||||
|
.listObjectsV2({ Bucket: BUCKET, Prefix: prefix })
|
||||||
|
.promise();
|
||||||
if (!list.Contents || list.Contents.length === 0) {
|
if (!list.Contents || list.Contents.length === 0) {
|
||||||
return res.status(404).json({ error: "Notebook not found" });
|
return res.status(404).json({ error: "Notebook not found" });
|
||||||
}
|
}
|
||||||
const latest = list.Contents.reduce((prev, curr) =>
|
const latest = list.Contents.reduce((prev, curr) =>
|
||||||
prev.LastModified > curr.LastModified ? prev : curr
|
prev.LastModified > curr.LastModified ? prev : curr
|
||||||
);
|
);
|
||||||
const data = await s3.getObject({ Bucket: BUCKET, Key: latest.Key }).promise();
|
const data = await s3
|
||||||
|
.getObject({ Bucket: BUCKET, Key: latest.Key })
|
||||||
|
.promise();
|
||||||
res.send(data.Body.toString("utf-8"));
|
res.send(data.Body.toString("utf-8"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load notebook:", error);
|
console.error("Failed to load notebook:", error);
|
||||||
|
|
@ -71,4 +78,6 @@ app.get("/notebook/:appName", async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = process.env.NODE_PORT || 8080;
|
const port = process.env.NODE_PORT || 8080;
|
||||||
app.listen({ port: port, host: '::', ipv6Only: false }, () => console.log(`Listening on ${port}...`));
|
app.listen({ port: port, host: "::", ipv6Only: false }, () =>
|
||||||
|
console.log(`Listening on ${port}...`)
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ RUN npm ci --only=production
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY snakeapi_service ./snakeapi_service
|
COPY snakeapi_service ./snakeapi_service
|
||||||
|
|
||||||
ENV FLY_ACCESS_TOKEN="FlyV1 fm2_lJPECAAAAAAACJJHxBByW6wRXXxQ17OD8xlRRR5cwrVodHRwczovL2FwaS5mbHkuaW8vdjGUAJLOAA//nh8Lk7lodHRwczovL2FwaS5mbHkuaW8vYWFhL3YxxDwQBQg0Vif1OLMYOJOtNVokX+9SIVL2E8QoNub0JDBE4wNh97aUAPiiNvpAAMhM/eO7SWUVAx5rcDTBjf7ETtdnvXtcHaqOnK2HmNSV9K9UVy5Or3Sd+0+kxqDoWRXGE0y5pdb8+HNqwMcryszYvAv8HVcoKFgF4qd7GmzniNvZETOkrbsjMsU1+mVXTMQgh7H9z6IcGVjJozV92cDsSn91USqxOmBdwFQAkFGwPV0=,fm2_lJPETtdnvXtcHaqOnK2HmNSV9K9UVy5Or3Sd+0+kxqDoWRXGE0y5pdb8+HNqwMcryszYvAv8HVcoKFgF4qd7GmzniNvZETOkrbsjMsU1+mVXTMQQqnJP464DwxC6D4e3p9THZMO5aHR0cHM6Ly9hcGkuZmx5LmlvL2FhYS92MZgEks5oESI9zwAAAAEkCUBbF84AD2FZCpHOAA9hWQzEEL6gO8olFxMOq1uFxP1yJavEIBDKb7RuqVr/sFQniKl0S2HMM6+AQJH3940ly0mufbYx"
|
ENV FLY_ACCESS_TOKEN="FlyV1 fm2_lJPECAAAAAAACJJHxBAjRF69RAjf3FXXuVT+M3bcwrVodHRwczovL2FwaS5mbHkuaW8vdjGUAJLOAA//nh8Lk7lodHRwczovL2FwaS5mbHkuaW8vYWFhL3YxxDxmIdNTu/DGjUSyYxuC5W7Rio4bNT5w6c1Ihi+ZJnjcmEutbt5KuyFcCo1C0CFPEhrP4hY5SEvXN58GHUDEToWZ0GwI5ndmIsZnhWSG8TBixbuFTaBb8lTBU5lNOvm2l4rX1i6dfId7S9Ko6qXpOzl9oYngy0zw+g2MwXuQrH6/XELBdEy/KThVeTEjt8QgBzOo/Eae+DsrATm6WjVv9f5a4iS/s7WtYHydZZr3z9M=,fm2_lJPEToWZ0GwI5ndmIsZnhWSG8TBixbuFTaBb8lTBU5lNOvm2l4rX1i6dfId7S9Ko6qXpOzl9oYngy0zw+g2MwXuQrH6/XELBdEy/KThVeTEjt8QQNZaUoOrVdOnk6Vo/DkeMGsO5aHR0cHM6Ly9hcGkuZmx5LmlvL2FhYS92MZgEks5oGwzFzwAAAAEkEyrjF84AD2FZCpHOAA9hWQzEEASQrBHkPDFO3LlZDaxRRIjEIEW1ki/syKHnhFamHgze8PFeunPOAmNmh57hslV04lL7"
|
||||||
|
|
||||||
EXPOSE 3006
|
EXPOSE 3006
|
||||||
CMD ["node", "src/index.js"]
|
CMD ["node", "src/index.js"]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ app = 'deployment-service-test'
|
||||||
primary_region = 'sea'
|
primary_region = 'sea'
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
dockerfile = "Dockerfile"
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
FLY_ORG="personal"
|
FLY_ORG="personal"
|
||||||
|
|
@ -15,5 +15,5 @@ dockerfile = "Dockerfile"
|
||||||
FLY_API_BASE_URL = "https://api.machines.dev/v1"
|
FLY_API_BASE_URL = "https://api.machines.dev/v1"
|
||||||
|
|
||||||
[http_service]
|
[http_service]
|
||||||
internal_port = 3006
|
internal_port = 3006
|
||||||
force_https = true
|
force_https = true
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -220,6 +220,28 @@ app.post("/:appName/upload", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// restart a Fly app
|
||||||
|
app.post("/:appName/restart", async (req, res) => {
|
||||||
|
const { appName } = req.params;
|
||||||
|
try {
|
||||||
|
const fly = createFlyClient();
|
||||||
|
const { data: machines } = await fly.get(`/apps/${appName}/machines`);
|
||||||
|
if (!machines || !Array.isArray(machines) || machines.length === 0) {
|
||||||
|
return res.status(404).json({ error: "No machines found for this app" });
|
||||||
|
}
|
||||||
|
const results = await Promise.all(
|
||||||
|
machines.map(machine =>
|
||||||
|
fly.post(`/apps/${appName}/machines/${machine.id}/restart`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
res.json({ status: "restarted", app: appName, count: results.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Restart error:", err.response?.data || err.message);
|
||||||
|
res.status(500).json({ error: err.response?.data || err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Delete a Fly app
|
// Delete a Fly app
|
||||||
app.post("/:appName/delete", async (req, res) => {
|
app.post("/:appName/delete", async (req, res) => {
|
||||||
const { appName } = req.params;
|
const { appName } = req.params;
|
||||||
|
|
|
||||||
22
gameboard-service/.dockerignore
Normal file
22
gameboard-service/.dockerignore
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# SvelteKit
|
||||||
|
.output
|
||||||
|
.svelte-kit
|
||||||
|
/build
|
||||||
|
/package
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Netlify
|
||||||
|
.netlify
|
||||||
18
gameboard-service/.github/workflows/fly-deploy.yml
vendored
Normal file
18
gameboard-service/.github/workflows/fly-deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/
|
||||||
|
|
||||||
|
name: Fly Deploy
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy app
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: deploy-group # optional: ensure only one action runs at a time
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
|
- run: flyctl deploy --remote-only
|
||||||
|
env:
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
47
gameboard-service/Dockerfile
Normal file
47
gameboard-service/Dockerfile
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# syntax = docker/dockerfile:1
|
||||||
|
|
||||||
|
# Adjust NODE_VERSION as desired
|
||||||
|
ARG NODE_VERSION=22.13.0
|
||||||
|
FROM node:${NODE_VERSION}-slim AS base
|
||||||
|
|
||||||
|
LABEL fly_launch_runtime="SvelteKit"
|
||||||
|
|
||||||
|
# SvelteKit app lives here
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set production environment
|
||||||
|
ENV NODE_ENV="production"
|
||||||
|
ENV PORT="3005"
|
||||||
|
|
||||||
|
# Throw-away build stage to reduce size of final image
|
||||||
|
FROM base AS build
|
||||||
|
|
||||||
|
# Install packages needed to build node modules
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3
|
||||||
|
|
||||||
|
# Install node modules
|
||||||
|
COPY .npmrc package-lock.json package.json ./
|
||||||
|
RUN npm ci --include=dev
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Remove development dependencies
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
|
||||||
|
# Final stage for app image
|
||||||
|
FROM base
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=build /app/build /app/build
|
||||||
|
COPY --from=build /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build /app/package.json /app
|
||||||
|
|
||||||
|
# Start the server by default, this can be overwritten at runtime
|
||||||
|
EXPOSE 3005
|
||||||
|
CMD [ "node", "./build/index.js" ]
|
||||||
20
gameboard-service/fly.toml
Normal file
20
gameboard-service/fly.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
app = "gameboard-service-aged-glitter-8141"
|
||||||
|
primary_region = "sea"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
PORT = "3005"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
|
||||||
|
[http_service]
|
||||||
|
internal_port = 3005
|
||||||
|
force_https = true
|
||||||
|
auto_stop_machines = "stop"
|
||||||
|
auto_start_machines = true
|
||||||
|
min_machines_running = 0
|
||||||
|
processes = ["app"]
|
||||||
|
|
||||||
|
[[vm]]
|
||||||
|
memory = "1gb"
|
||||||
|
cpu_kind = "shared"
|
||||||
|
cpus = 1
|
||||||
991
gameboard-service/package-lock.json
generated
991
gameboard-service/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -15,11 +15,13 @@
|
||||||
"test:unit": "vitest run"
|
"test:unit": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@flydotio/dockerfile": "^0.7.10",
|
||||||
"@iconify-json/heroicons": "^1.1.11",
|
"@iconify-json/heroicons": "^1.1.11",
|
||||||
"@iconify-json/heroicons-solid": "^1.1.7",
|
"@iconify-json/heroicons-solid": "^1.1.7",
|
||||||
"@neoconfetti/svelte": "^1.0.0",
|
"@neoconfetti/svelte": "^1.0.0",
|
||||||
"@playwright/test": "^1.28.1",
|
"@playwright/test": "^1.28.1",
|
||||||
"@sveltejs/adapter-auto": "^2.0.0",
|
"@sveltejs/adapter-auto": "^2.0.0",
|
||||||
|
"@sveltejs/adapter-node": "^1.3.1",
|
||||||
"@sveltejs/adapter-static": "^2.0.3",
|
"@sveltejs/adapter-static": "^2.0.3",
|
||||||
"@sveltejs/kit": "^1.30.3",
|
"@sveltejs/kit": "^1.30.3",
|
||||||
"@tailwindcss/forms": "^0.5.4",
|
"@tailwindcss/forms": "^0.5.4",
|
||||||
|
|
|
||||||
12
gameboard-service/src/hooks.server.ts
Normal file
12
gameboard-service/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
const response = await resolve(event);
|
||||||
|
|
||||||
|
response.headers.delete('X-Frame-Options');
|
||||||
|
response.headers.set('X-Frame-Options', 'ALLOWALL');
|
||||||
|
|
||||||
|
response.headers.set('Content-Security-Policy', "frame-ancestors *");
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||||
// import adapter from '@sveltejs/adapter-auto';
|
// import adapter from '@sveltejs/adapter-node';
|
||||||
import adapter from "@sveltejs/adapter-static";
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
// import adapter from "@sveltejs/adapter-static";
|
||||||
|
|
||||||
import { vitePreprocess } from "@sveltejs/kit/vite";
|
import { vitePreprocess } from '@sveltejs/kit/vite';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
|
|
@ -13,12 +14,8 @@ const config = {
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
// These are the defaults, see https://kit.svelte.dev/docs/adapter-static
|
// This option specifies the output directory for the build
|
||||||
pages: "build",
|
out: 'build'
|
||||||
assets: "build",
|
|
||||||
fallback: undefined,
|
|
||||||
precompress: false,
|
|
||||||
strict: true
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ mkdir -p "${NOTEBOOK_DIR}"
|
||||||
# fetch latest notebook
|
# fetch latest notebook
|
||||||
echo "Syncing notebooks from S3 bucket..."
|
echo "Syncing notebooks from S3 bucket..."
|
||||||
aws --endpoint-url "$AWS_ENDPOINT_URL_S3" --region "$AWS_REGION" \
|
aws --endpoint-url "$AWS_ENDPOINT_URL_S3" --region "$AWS_REGION" \
|
||||||
s3 sync "s3://$BUCKET_NAME/$INSTANCE_PREFIX/notebooks/" "${NOTEBOOK_DIR}/"
|
s3 sync "s3://$COMMON_BUCKET/$INSTANCE_PREFIX/notebooks/" "${NOTEBOOK_DIR}/"
|
||||||
|
|
||||||
# convert to Python script for dynamic import
|
# convert to Python script for dynamic import
|
||||||
echo "Finding the latest notebook..."
|
echo "Finding the latest notebook..."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue